فهرست منبع

Merge branch 'main' into demo

# Conflicts:
#	src/lib/cron/low-stock-alerts.ts
Bernt Christian Egeland 1 ماه پیش
والد
کامیت
ac915a72fe
100فایلهای تغییر یافته به همراه5814 افزوده شده و 2677 حذف شده
  1. 8 2
      messages/de/audit.json
  2. 1 0
      messages/de/dashboard.json
  3. 8 0
      messages/de/inventory.json
  4. 4 1
      messages/de/service.json
  5. 2 0
      messages/de/settings.json
  6. 79 4
      messages/de/workBoard.json
  7. 8 2
      messages/en/audit.json
  8. 1 0
      messages/en/dashboard.json
  9. 8 0
      messages/en/inventory.json
  10. 4 1
      messages/en/service.json
  11. 2 0
      messages/en/settings.json
  12. 79 4
      messages/en/workBoard.json
  13. 8 2
      messages/es/audit.json
  14. 1 0
      messages/es/dashboard.json
  15. 8 0
      messages/es/inventory.json
  16. 4 1
      messages/es/service.json
  17. 2 0
      messages/es/settings.json
  18. 79 4
      messages/es/workBoard.json
  19. 8 2
      messages/fr/audit.json
  20. 1 0
      messages/fr/dashboard.json
  21. 8 0
      messages/fr/inventory.json
  22. 4 1
      messages/fr/service.json
  23. 2 0
      messages/fr/settings.json
  24. 79 4
      messages/fr/workBoard.json
  25. 8 2
      messages/it/audit.json
  26. 1 0
      messages/it/dashboard.json
  27. 8 0
      messages/it/inventory.json
  28. 4 1
      messages/it/service.json
  29. 2 0
      messages/it/settings.json
  30. 79 4
      messages/it/workBoard.json
  31. 8 2
      messages/lt/audit.json
  32. 1 0
      messages/lt/dashboard.json
  33. 8 0
      messages/lt/inventory.json
  34. 4 1
      messages/lt/service.json
  35. 2 0
      messages/lt/settings.json
  36. 79 4
      messages/lt/workBoard.json
  37. 8 2
      messages/nb/audit.json
  38. 1 0
      messages/nb/dashboard.json
  39. 8 0
      messages/nb/inventory.json
  40. 4 1
      messages/nb/service.json
  41. 2 0
      messages/nb/settings.json
  42. 79 4
      messages/nb/workBoard.json
  43. 8 2
      messages/nl/audit.json
  44. 1 0
      messages/nl/dashboard.json
  45. 8 0
      messages/nl/inventory.json
  46. 4 1
      messages/nl/service.json
  47. 2 0
      messages/nl/settings.json
  48. 79 4
      messages/nl/workBoard.json
  49. 8 2
      messages/pl/audit.json
  50. 1 0
      messages/pl/dashboard.json
  51. 8 0
      messages/pl/inventory.json
  52. 4 1
      messages/pl/service.json
  53. 2 0
      messages/pl/settings.json
  54. 79 4
      messages/pl/workBoard.json
  55. 8 2
      messages/pt-BR/audit.json
  56. 1 0
      messages/pt-BR/dashboard.json
  57. 8 0
      messages/pt-BR/inventory.json
  58. 4 1
      messages/pt-BR/service.json
  59. 2 0
      messages/pt-BR/settings.json
  60. 79 4
      messages/pt-BR/workBoard.json
  61. 8 2
      messages/ru/audit.json
  62. 1 0
      messages/ru/dashboard.json
  63. 8 0
      messages/ru/inventory.json
  64. 4 1
      messages/ru/service.json
  65. 2 0
      messages/ru/settings.json
  66. 79 4
      messages/ru/workBoard.json
  67. 8 2
      messages/tr/audit.json
  68. 1 0
      messages/tr/dashboard.json
  69. 8 0
      messages/tr/inventory.json
  70. 4 1
      messages/tr/service.json
  71. 2 0
      messages/tr/settings.json
  72. 79 4
      messages/tr/workBoard.json
  73. 38 0
      prisma/migrations/20260825120000_work_bays/migration.sql
  74. 23 0
      prisma/migrations/20260826155804_inventory_units_fractional_stock/migration.sql
  75. 83 30
      prisma/schema.prisma
  76. 74 0
      src/__tests__/features/backup/part-restore-fields.test.ts
  77. 9 0
      src/__tests__/features/inventory/low-stock-alerts.test.ts
  78. 17 4
      src/__tests__/features/inventory/reconcile-stock.test.ts
  79. 42 0
      src/__tests__/features/inventory/units.test.ts
  80. 1 0
      src/__tests__/features/tire-hotel/add-tire-line.test.ts
  81. 2 0
      src/__tests__/features/workboard/datetime.test.ts
  82. 416 0
      src/__tests__/features/workboard/week-layout.test.ts
  83. 50 0
      src/__tests__/features/workboard/week-number.test.ts
  84. 32 0
      src/__tests__/features/workorders/add-part.test.ts
  85. 45 48
      src/app/(authenticated)/inventory/[id]/page.tsx
  86. 130 113
      src/app/(authenticated)/inventory/[id]/part-detail-client.tsx
  87. 439 359
      src/app/(authenticated)/inventory/inventory-client.tsx
  88. 39 27
      src/app/(authenticated)/inventory/page.tsx
  89. 229 178
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  90. 41 26
      src/app/(authenticated)/labor-presets/page.tsx
  91. 6 1
      src/app/(authenticated)/layout.tsx
  92. 498 374
      src/app/(authenticated)/reports/reports-client.tsx
  93. 157 121
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  94. 8 1
      src/app/(authenticated)/vehicles/[id]/service/new/page.tsx
  95. 45 31
      src/app/(authenticated)/work-board/page.tsx
  96. 36 32
      src/app/(presenter)/work-board/presenter/page.tsx
  97. 1001 523
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  98. 115 104
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  99. 86 74
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  100. 928 547
      src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx

+ 8 - 2
messages/de/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Reifensatz ausgemustert",
     "tire_set_attach": "Dateien zu einem Reifensatz hinzugefügt",
     "tire_set_detach": "Datei von einem Reifensatz entfernt",
-    "whatsapp_send": "WhatsApp-Nachricht gesendet"
+    "whatsapp_send": "WhatsApp-Nachricht gesendet",
+    "work_bay_create": "Arbeitsplatz erstellt",
+    "work_bay_update": "Arbeitsplatz aktualisiert",
+    "work_bay_delete": "Arbeitsplatz gelöscht"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -256,6 +259,9 @@
     "vehicle_update": "Fahrzeug {name} aktualisiert, geändert: {fields}",
     "vehicle_updateNoChanges": "Fahrzeug {name} aktualisiert, nichts geändert",
     "webhook_create": "Webhook {name} erstellt",
-    "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet"
+    "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet",
+    "work_bay_create": "Arbeitsplatz „{name}“ angelegt",
+    "work_bay_update": "Arbeitsplatz „{name}“ aktualisiert",
+    "work_bay_delete": "Arbeitsplatz {id} gelöscht"
   }
 }

+ 1 - 0
messages/de/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategorie",
       "quantity": "Menge",
       "minQuantity": "Mindestmenge",
+      "unit": "Einheit",
       "unitCost": "Kosten",
       "sellPrice": "Preis",
       "supplier": "Lieferant"

+ 8 - 0
messages/de/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Regal A-3",
     "quantity": "Menge",
     "minQty": "Mindestmenge",
+    "unit": "Einheit",
+    "unitPlaceholder": "Stk, l, kg...",
+    "unitSuggestions": "Stk,Satz",
+    "unitSearch": "Suchen oder eingeben...",
+    "unitCreate": "\"{name}\" verwenden",
+    "unitGroupCount": "Zähleinheiten",
+    "unitGroupMetric": "Metrisch",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Stückkosten",
     "sellPrice": "Verkaufspreis",
     "supplier": "Lieferant",

+ 4 - 1
messages/de/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Mehr erfahren",
-    "setDefaults": "Standardwerte"
+    "setDefaults": "Standardwerte",
+    "workBay": "Arbeitsplatz",
+    "selectWorkBay": "Arbeitsplatz wählen …",
+    "noWorkBay": "Kein Arbeitsplatz"
   },
   "notifications": {
     "title": "Kundenbenachrichtigungen",

+ 2 - 0
messages/de/settings.json

@@ -831,6 +831,8 @@
     "liters": "Liter (L)",
     "miles": "Meilen (mi)",
     "gallons": "Gallonen (gal)",
+    "defaultStockUnit": "Standard-Mengeneinheit",
+    "defaultStockUnitHint": "Wird bei neuen Lagerteilen vorausgefüllt. Jedes Teil kann weiterhin eine eigene Einheit haben.",
     "saveWorkshop": "Werkstatteinstellungen speichern",
     "saved": "Werkstatteinstellungen gespeichert",
     "assignDialogTitle": "Bestehenden Aufträgen zuweisen?",

+ 79 - 4
messages/de/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Spalten",
+    "byTechnician": "Eine Spalte je Mechaniker",
+    "byBay": "Eine Spalte je Arbeitsplatz",
+    "byNothing": "Eine Spalte je Tag",
+    "weekends": "Wochenende",
+    "add": "Hinzufügen",
+    "addBay": "Arbeitsplatz hinzufügen",
+    "weekNumber": "KW {number}",
+    "lanes": "Spalten",
+    "showAllLanes": "Alle anzeigen",
+    "onlyBusyLanes": "Nur mit Aufträgen",
+    "layout": "Ansicht",
+    "layoutTimeline": "Zeitachse",
+    "layoutCards": "Übersicht",
+    "rows": "Zeilen",
+    "rowPerTechnician": "Eine Zeile je Mechaniker",
+    "rowPerBay": "Eine Zeile je Arbeitsplatz",
+    "rowNothing": "Eine einzige Zeile",
+    "technicians": "Mechaniker",
+    "workBays": "Arbeitsplätze",
+    "weekendsDayHint": "Gilt nur in der Wochenansicht"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Zuweisung konnte nicht verschoben werden",
     "failedAssign": "Auftrag konnte nicht zugewiesen werden",
     "failedRemove": "Zuweisung konnte nicht entfernt werden",
-    "createNewWorkorder": "Neuen Arbeitsauftrag erstellen"
+    "createNewWorkorder": "Neuen Arbeitsauftrag erstellen",
+    "noBays": "Noch keine Arbeitsplätze. Legen Sie einen an, um die Woche nach Arbeitsplatz zu planen.",
+    "addFirstBay": "Arbeitsplatz hinzufügen",
+    "lanes": {
+      "noBay": "Ohne Arbeitsplatz",
+      "noTechnician": "Ohne Mechaniker",
+      "everyone": "Alle Arbeiten",
+      "thisWeek": "Diese Woche"
+    },
+    "dropNeedsLane": "Gruppieren Sie das Board nach Mechaniker oder Arbeitsplatz, bevor Sie einen Auftrag darauf ziehen."
   },
   "unassigned": {
     "title": "Nicht zugewiesene Aufträge ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "week": "Woche",
     "today": "Heute",
-    "timeline": "Zeitleiste",
     "live": "Live",
     "disconnected": "Live-Updates unterbrochen",
     "noTechnicians": "Keine Techniker konfiguriert",
@@ -76,6 +105,52 @@
       "completed": "Abgeschlossen"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Die Statustafel gruppiert nach Status",
+    "connecting": "Verbinden …"
+  },
+  "week": {
+    "createAt": "Neuer Auftrag um {time}",
+    "unscheduled": "Nicht eingeplant ({count})",
+    "hint": "Auftrag ziehen zum Verschieben. Oberen oder unteren Rand ziehen, um die Dauer zu ändern.",
+    "hiddenDays": "{count} am Wochenende eingeplant. Wochenende anzeigen.",
+    "panLeft": "Früher in der Woche",
+    "panRight": "Später in der Woche",
+    "hintPan": "Hintergrund ziehen, um durch die Woche zu blättern.",
+    "wholeWeek": "Ganze Woche anzeigen",
+    "hintZoom": "Strg halten und scrollen zum Zoomen."
+  },
+  "bay": {
+    "addTitle": "Arbeitsplatz hinzufügen",
+    "editTitle": "Arbeitsplatz bearbeiten",
+    "description": "Ein Ort, an dem gearbeitet wird: Bühne, Rampe oder Waschplatz.",
+    "name": "Name",
+    "namePlaceholder": "z. B. Bühne 1",
+    "color": "Farbe",
+    "dailyCapacity": "Buchbare Stunden pro Tag",
+    "hoursPerDay": "Stunden pro Tag",
+    "capacityHint": "Zeigt an, wie voll jeder Tag ist.",
+    "deleteTitle": "Arbeitsplatz löschen",
+    "deleteDescription": "„{name}“ entfernen? Aufträge darin bleiben eingeplant und wechseln in die Spalte ohne Arbeitsplatz.",
+    "addBay": "Arbeitsplatz hinzufügen",
+    "saveFailed": "Arbeitsplatz konnte nicht gespeichert werden"
+  },
+  "lane": {
+    "booked": "Verplant",
+    "capacity": "Kapazität",
+    "free": "Frei",
+    "over": "Überbucht um",
+    "jobs": "Aufträge",
+    "utilisation": "{pct} % der Kapazität",
+    "placeholder": "Arbeiten, die hier noch niemandem zugeordnet sind."
+  },
+  "job": {
+    "time": "Zeit",
+    "duration": "Dauer",
+    "technician": "Mechaniker",
+    "workBay": "Arbeitsplatz",
+    "status": "Status",
+    "noTime": "Noch keine Zeit",
+    "none": "Noch niemand"
   }
 }

+ 8 - 2
messages/en/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Wrote off a tire set",
     "tire_set_attach": "Added files to a tire set",
     "tire_set_detach": "Removed a file from a tire set",
-    "whatsapp_send": "Sent WhatsApp Message"
+    "whatsapp_send": "Sent WhatsApp Message",
+    "work_bay_create": "Created Work Bay",
+    "work_bay_update": "Updated Work Bay",
+    "work_bay_delete": "Deleted Work Bay"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -256,6 +259,9 @@
     "vehicle_update": "Updated vehicle {name}, changed: {fields}",
     "vehicle_updateNoChanges": "Updated vehicle {name}, nothing changed",
     "webhook_create": "Created webhook {name}",
-    "whatsapp_send": "Sent a WhatsApp message to {name}"
+    "whatsapp_send": "Sent a WhatsApp message to {name}",
+    "work_bay_create": "Created work bay \"{name}\"",
+    "work_bay_update": "Updated work bay \"{name}\"",
+    "work_bay_delete": "Deleted work bay {id}"
   }
 }

+ 1 - 0
messages/en/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Category",
       "quantity": "Quantity",
       "minQuantity": "Min quantity",
+      "unit": "Unit",
       "unitCost": "Cost",
       "sellPrice": "Price",
       "supplier": "Supplier"

+ 8 - 0
messages/en/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Shelf A-3",
     "quantity": "Quantity",
     "minQty": "Min Qty",
+    "unit": "Unit",
+    "unitPlaceholder": "pcs, l, kg...",
+    "unitSuggestions": "pcs,set",
+    "unitSearch": "Search or type...",
+    "unitCreate": "Use \"{name}\"",
+    "unitGroupCount": "Count",
+    "unitGroupMetric": "Metric",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Unit Cost",
     "sellPrice": "Sell Price",
     "supplier": "Supplier",

+ 4 - 1
messages/en/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Read More",
-    "setDefaults": "Set Defaults"
+    "setDefaults": "Set Defaults",
+    "workBay": "Work bay",
+    "selectWorkBay": "Select work bay...",
+    "noWorkBay": "No work bay"
   },
   "notifications": {
     "title": "Customer Notifications",

+ 2 - 0
messages/en/settings.json

@@ -831,6 +831,8 @@
     "liters": "liters (L)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Default stock unit",
+    "defaultStockUnitHint": "Pre-filled on new inventory parts. Each part can still use its own unit.",
     "saveWorkshop": "Save Workshop Settings",
     "saved": "Workshop settings saved",
     "assignDialogTitle": "Assign to existing work orders?",

+ 79 - 4
messages/en/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Columns",
+    "byTechnician": "A column per technician",
+    "byBay": "A column per work bay",
+    "byNothing": "A column per day",
+    "weekends": "Weekends",
+    "add": "Add",
+    "addBay": "Add work bay",
+    "weekNumber": "Week {number}",
+    "lanes": "Lanes",
+    "showAllLanes": "Show all",
+    "onlyBusyLanes": "Only those with work",
+    "layout": "Layout",
+    "layoutTimeline": "Timeline",
+    "layoutCards": "Overview",
+    "rows": "Rows",
+    "rowPerTechnician": "A row per technician",
+    "rowPerBay": "A row per work bay",
+    "rowNothing": "A single row",
+    "technicians": "Technicians",
+    "workBays": "Work bays",
+    "weekendsDayHint": "Only applies when a week is shown"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Failed to move assignment",
     "failedAssign": "Failed to assign job",
     "failedRemove": "Failed to remove assignment",
-    "createNewWorkorder": "Create new Workorder"
+    "createNewWorkorder": "Create new Workorder",
+    "noBays": "No work bays yet. Add one to plan the week bay by bay.",
+    "addFirstBay": "Add work bay",
+    "lanes": {
+      "noBay": "No bay",
+      "noTechnician": "No technician",
+      "everyone": "All work",
+      "thisWeek": "This week"
+    },
+    "dropNeedsLane": "Group the board by technician or work bay before dropping a job onto it."
   },
   "unassigned": {
     "title": "Unassigned Jobs ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "week": "Week",
     "today": "Today",
-    "timeline": "Timeline",
     "live": "Live",
     "disconnected": "Live updates disconnected",
     "noTechnicians": "No technicians configured",
@@ -76,6 +105,52 @@
       "completed": "Completed"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "The status board groups by status",
+    "connecting": "Connecting…"
+  },
+  "week": {
+    "createAt": "New work order at {time}",
+    "unscheduled": "Not scheduled ({count})",
+    "hint": "Drag a job to move it. Drag its top or bottom edge to change how long it takes.",
+    "hiddenDays": "{count} booked on the weekend. Show weekends.",
+    "panLeft": "Earlier in the week",
+    "panRight": "Later in the week",
+    "hintPan": "Drag the background to move across the week.",
+    "wholeWeek": "See the whole week",
+    "hintZoom": "Hold Ctrl and scroll to zoom."
+  },
+  "bay": {
+    "addTitle": "Add work bay",
+    "editTitle": "Edit work bay",
+    "description": "A place where work happens: a lift, a ramp, a wash bay.",
+    "name": "Name",
+    "namePlaceholder": "e.g. Lift 1",
+    "color": "Color",
+    "dailyCapacity": "Bookable hours per day",
+    "hoursPerDay": "hours per day",
+    "capacityHint": "Used to show how full each day is.",
+    "deleteTitle": "Delete work bay",
+    "deleteDescription": "Remove \"{name}\"? Jobs booked into it stay scheduled and move to the no-bay column.",
+    "addBay": "Add work bay",
+    "saveFailed": "Could not save the work bay"
+  },
+  "lane": {
+    "booked": "Booked",
+    "capacity": "Capacity",
+    "free": "Free",
+    "over": "Over by",
+    "jobs": "Jobs",
+    "utilisation": "{pct}% of capacity",
+    "placeholder": "Work with nothing assigned here yet."
+  },
+  "job": {
+    "time": "Time",
+    "duration": "Duration",
+    "technician": "Technician",
+    "workBay": "Work bay",
+    "status": "Status",
+    "noTime": "No time set yet",
+    "none": "Nobody yet"
   }
 }

+ 8 - 2
messages/es/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Juego de neumáticos dado de baja",
     "tire_set_attach": "Archivos añadidos a un juego de neumáticos",
     "tire_set_detach": "Archivo quitado de un juego de neumáticos",
-    "whatsapp_send": "Mensaje de WhatsApp enviado"
+    "whatsapp_send": "Mensaje de WhatsApp enviado",
+    "work_bay_create": "Puesto de trabajo creado",
+    "work_bay_update": "Puesto de trabajo actualizado",
+    "work_bay_delete": "Puesto de trabajo eliminado"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
@@ -256,6 +259,9 @@
     "vehicle_update": "Vehículo {name} actualizado, cambios: {fields}",
     "vehicle_updateNoChanges": "Vehículo {name} actualizado, sin cambios",
     "webhook_create": "Webhook {name} creado",
-    "whatsapp_send": "Envió un mensaje de WhatsApp a {name}"
+    "whatsapp_send": "Envió un mensaje de WhatsApp a {name}",
+    "work_bay_create": "Puesto de trabajo «{name}» creado",
+    "work_bay_update": "Puesto de trabajo «{name}» actualizado",
+    "work_bay_delete": "Puesto de trabajo {id} eliminado"
   }
 }

+ 1 - 0
messages/es/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categoría",
       "quantity": "Cantidad",
       "minQuantity": "Cantidad mínima",
+      "unit": "Unidad",
       "unitCost": "Coste",
       "sellPrice": "Precio",
       "supplier": "Proveedor"

+ 8 - 0
messages/es/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Estante A-3",
     "quantity": "Cantidad",
     "minQty": "Cant. mín.",
+    "unit": "Unidad",
+    "unitPlaceholder": "uds, l, kg...",
+    "unitSuggestions": "uds,juego",
+    "unitSearch": "Buscar o escribir...",
+    "unitCreate": "Usar \"{name}\"",
+    "unitGroupCount": "Recuento",
+    "unitGroupMetric": "Métrico",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Coste unitario",
     "sellPrice": "Precio de venta",
     "supplier": "Proveedor",

+ 4 - 1
messages/es/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Leer más",
-    "setDefaults": "Configurar valores"
+    "setDefaults": "Configurar valores",
+    "workBay": "Puesto de trabajo",
+    "selectWorkBay": "Seleccionar puesto…",
+    "noWorkBay": "Sin puesto"
   },
   "notifications": {
     "title": "Notificaciones al cliente",

+ 2 - 0
messages/es/settings.json

@@ -831,6 +831,8 @@
     "liters": "litros (L)",
     "miles": "millas (mi)",
     "gallons": "galones (gal)",
+    "defaultStockUnit": "Unidad de stock predeterminada",
+    "defaultStockUnitHint": "Se rellena automáticamente en las piezas nuevas del inventario. Cada pieza puede seguir usando su propia unidad.",
     "saveWorkshop": "Guardar configuración del taller",
     "saved": "Configuración del taller guardada",
     "assignDialogTitle": "¿Asignar a órdenes de trabajo existentes?",

+ 79 - 4
messages/es/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Columnas",
+    "byTechnician": "Una columna por técnico",
+    "byBay": "Una columna por puesto",
+    "byNothing": "Una columna por día",
+    "weekends": "Fin de semana",
+    "add": "Añadir",
+    "addBay": "Añadir puesto",
+    "weekNumber": "Sem. {number}",
+    "lanes": "Columnas",
+    "showAllLanes": "Mostrar todas",
+    "onlyBusyLanes": "Solo con trabajo",
+    "layout": "Vista",
+    "layoutTimeline": "Cronología",
+    "layoutCards": "Resumen",
+    "rows": "Filas",
+    "rowPerTechnician": "Una fila por técnico",
+    "rowPerBay": "Una fila por puesto",
+    "rowNothing": "Una sola fila",
+    "technicians": "Técnicos",
+    "workBays": "Puestos de trabajo",
+    "weekendsDayHint": "Solo se aplica al ver una semana"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Error al mover asignación",
     "failedAssign": "Error al asignar trabajo",
     "failedRemove": "Error al eliminar asignación",
-    "createNewWorkorder": "Crear nueva orden de trabajo"
+    "createNewWorkorder": "Crear nueva orden de trabajo",
+    "noBays": "Aún no hay puestos de trabajo. Añade uno para planificar la semana por puesto.",
+    "addFirstBay": "Añadir puesto",
+    "lanes": {
+      "noBay": "Sin puesto",
+      "noTechnician": "Sin técnico",
+      "everyone": "Todo el trabajo",
+      "thisWeek": "Esta semana"
+    },
+    "dropNeedsLane": "Agrupa el tablero por técnico o puesto de trabajo antes de soltar un trabajo en él."
   },
   "unassigned": {
     "title": "Trabajos Sin Asignar ({count})",
@@ -62,7 +92,6 @@
     "status": "Estado",
     "week": "Semana",
     "today": "Hoy",
-    "timeline": "Línea de tiempo",
     "live": "En Vivo",
     "disconnected": "Actualizaciones en vivo desconectadas",
     "noTechnicians": "Sin técnicos configurados",
@@ -76,6 +105,52 @@
       "completed": "Completado"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "El tablero de estado agrupa por estado",
+    "connecting": "Conectando…"
+  },
+  "week": {
+    "createAt": "Nueva orden de trabajo a las {time}",
+    "unscheduled": "Sin programar ({count})",
+    "hint": "Arrastra un trabajo para moverlo. Arrastra su borde superior o inferior para cambiar la duración.",
+    "hiddenDays": "{count} programado(s) el fin de semana. Mostrar fin de semana.",
+    "panLeft": "Antes en la semana",
+    "panRight": "Después en la semana",
+    "hintPan": "Arrastra el fondo para recorrer la semana.",
+    "wholeWeek": "Ver la semana completa",
+    "hintZoom": "Mantén Ctrl y desplázate para hacer zoom."
+  },
+  "bay": {
+    "addTitle": "Añadir puesto de trabajo",
+    "editTitle": "Editar puesto de trabajo",
+    "description": "Un lugar donde se trabaja: un elevador, una rampa o una zona de lavado.",
+    "name": "Nombre",
+    "namePlaceholder": "p. ej. Elevador 1",
+    "color": "Color",
+    "dailyCapacity": "Horas reservables por día",
+    "hoursPerDay": "horas por día",
+    "capacityHint": "Sirve para ver cuán lleno está cada día.",
+    "deleteTitle": "Eliminar puesto de trabajo",
+    "deleteDescription": "¿Eliminar «{name}»? Los trabajos reservados en él siguen programados y pasan a la columna sin puesto.",
+    "addBay": "Añadir puesto",
+    "saveFailed": "No se pudo guardar el puesto de trabajo"
+  },
+  "lane": {
+    "booked": "Programado",
+    "capacity": "Capacidad",
+    "free": "Libre",
+    "over": "Excedido en",
+    "jobs": "Trabajos",
+    "utilisation": "{pct} % de la capacidad",
+    "placeholder": "Trabajo aún sin asignar aquí."
+  },
+  "job": {
+    "time": "Hora",
+    "duration": "Duración",
+    "technician": "Técnico",
+    "workBay": "Puesto de trabajo",
+    "status": "Estado",
+    "noTime": "Sin hora todavía",
+    "none": "Nadie todavía"
   }
 }

+ 8 - 2
messages/fr/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Jeu de pneus mis au rebut",
     "tire_set_attach": "Fichiers ajoutés à un jeu de pneus",
     "tire_set_detach": "Fichier retiré d’un jeu de pneus",
-    "whatsapp_send": "Message WhatsApp envoyé"
+    "whatsapp_send": "Message WhatsApp envoyé",
+    "work_bay_create": "Poste de travail créé",
+    "work_bay_update": "Poste de travail mis à jour",
+    "work_bay_delete": "Poste de travail supprimé"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -256,6 +259,9 @@
     "vehicle_update": "Véhicule {name} mis à jour, modifié : {fields}",
     "vehicle_updateNoChanges": "Véhicule {name} mis à jour, aucun changement",
     "webhook_create": "Webhook {name} créé",
-    "whatsapp_send": "Message WhatsApp envoyé à {name}"
+    "whatsapp_send": "Message WhatsApp envoyé à {name}",
+    "work_bay_create": "Poste de travail « {name} » créé",
+    "work_bay_update": "Poste de travail « {name} » mis à jour",
+    "work_bay_delete": "Poste de travail {id} supprimé"
   }
 }

+ 1 - 0
messages/fr/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Catégorie",
       "quantity": "Quantité",
       "minQuantity": "Quantité min.",
+      "unit": "Unité",
       "unitCost": "Coût",
       "sellPrice": "Prix",
       "supplier": "Fournisseur"

+ 8 - 0
messages/fr/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Étagère A-3",
     "quantity": "Quantité",
     "minQty": "Qté min.",
+    "unit": "Unité",
+    "unitPlaceholder": "pcs, l, kg...",
+    "unitSuggestions": "pcs,jeu",
+    "unitSearch": "Rechercher ou saisir...",
+    "unitCreate": "Utiliser \"{name}\"",
+    "unitGroupCount": "Comptage",
+    "unitGroupMetric": "Métrique",
+    "unitGroupImperial": "Impérial",
     "unitCost": "Coût unitaire",
     "sellPrice": "Prix de vente",
     "supplier": "Fournisseur",

+ 4 - 1
messages/fr/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "En savoir plus",
-    "setDefaults": "Paramètres par défaut"
+    "setDefaults": "Paramètres par défaut",
+    "workBay": "Poste de travail",
+    "selectWorkBay": "Choisir un poste…",
+    "noWorkBay": "Aucun poste"
   },
   "notifications": {
     "title": "Notifications client",

+ 2 - 0
messages/fr/settings.json

@@ -831,6 +831,8 @@
     "liters": "litres (L)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Unité de stock par défaut",
+    "defaultStockUnitHint": "Préremplie pour les nouvelles pièces en stock. Chaque pièce peut toujours utiliser sa propre unité.",
     "saveWorkshop": "Enregistrer les paramètres de l'atelier",
     "saved": "Paramètres de l'atelier enregistrés",
     "assignDialogTitle": "Assigner aux ordres de travail existants ?",

+ 79 - 4
messages/fr/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Colonnes",
+    "byTechnician": "Une colonne par technicien",
+    "byBay": "Une colonne par poste",
+    "byNothing": "Une colonne par jour",
+    "weekends": "Week-end",
+    "add": "Ajouter",
+    "addBay": "Ajouter un poste",
+    "weekNumber": "Sem. {number}",
+    "lanes": "Colonnes",
+    "showAllLanes": "Tout afficher",
+    "onlyBusyLanes": "Seulement avec du travail",
+    "layout": "Affichage",
+    "layoutTimeline": "Chronologie",
+    "layoutCards": "Vue d'ensemble",
+    "rows": "Lignes",
+    "rowPerTechnician": "Une ligne par technicien",
+    "rowPerBay": "Une ligne par poste",
+    "rowNothing": "Une seule ligne",
+    "technicians": "Techniciens",
+    "workBays": "Postes de travail",
+    "weekendsDayHint": "Ne s'applique qu'à une semaine"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Impossible de déplacer l'assignation",
     "failedAssign": "Impossible d'assigner le travail",
     "failedRemove": "Impossible de supprimer l'assignation",
-    "createNewWorkorder": "Créer un nouvel ordre de travail"
+    "createNewWorkorder": "Créer un nouvel ordre de travail",
+    "noBays": "Aucun poste de travail. Ajoutez-en un pour planifier la semaine par poste.",
+    "addFirstBay": "Ajouter un poste",
+    "lanes": {
+      "noBay": "Sans poste",
+      "noTechnician": "Sans technicien",
+      "everyone": "Tout le travail",
+      "thisWeek": "Cette semaine"
+    },
+    "dropNeedsLane": "Groupez le tableau par technicien ou par poste avant d'y déposer une tâche."
   },
   "unassigned": {
     "title": "Travaux Non Assignés ({count})",
@@ -62,7 +92,6 @@
     "status": "Statut",
     "week": "Semaine",
     "today": "Aujourd'hui",
-    "timeline": "Chronologie",
     "live": "En Direct",
     "disconnected": "Mises à jour en direct déconnectées",
     "noTechnicians": "Aucun technicien configuré",
@@ -76,6 +105,52 @@
       "completed": "Complété"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Le tableau de statut regroupe par statut",
+    "connecting": "Connexion…"
+  },
+  "week": {
+    "createAt": "Nouvel ordre de travail à {time}",
+    "unscheduled": "Non planifié ({count})",
+    "hint": "Faites glisser une tâche pour la déplacer. Faites glisser son bord haut ou bas pour changer sa durée.",
+    "hiddenDays": "{count} planifié(s) le week-end. Afficher le week-end.",
+    "panLeft": "Plus tôt dans la semaine",
+    "panRight": "Plus tard dans la semaine",
+    "hintPan": "Faites glisser le fond pour parcourir la semaine.",
+    "wholeWeek": "Voir toute la semaine",
+    "hintZoom": "Maintenez Ctrl et faites défiler pour zoomer."
+  },
+  "bay": {
+    "addTitle": "Ajouter un poste de travail",
+    "editTitle": "Modifier le poste de travail",
+    "description": "Un endroit où le travail se fait : un pont, une rampe, une aire de lavage.",
+    "name": "Nom",
+    "namePlaceholder": "ex. Pont 1",
+    "color": "Couleur",
+    "dailyCapacity": "Heures réservables par jour",
+    "hoursPerDay": "heures par jour",
+    "capacityHint": "Sert à montrer le taux de remplissage de chaque journée.",
+    "deleteTitle": "Supprimer le poste de travail",
+    "deleteDescription": "Supprimer « {name} » ? Les tâches qui y sont réservées restent planifiées et passent dans la colonne sans poste.",
+    "addBay": "Ajouter un poste",
+    "saveFailed": "Impossible d'enregistrer le poste de travail"
+  },
+  "lane": {
+    "booked": "Planifié",
+    "capacity": "Capacité",
+    "free": "Libre",
+    "over": "Dépassement de",
+    "jobs": "Tâches",
+    "utilisation": "{pct} % de la capacité",
+    "placeholder": "Travail encore non attribué ici."
+  },
+  "job": {
+    "time": "Heure",
+    "duration": "Durée",
+    "technician": "Technicien",
+    "workBay": "Poste de travail",
+    "status": "Statut",
+    "noTime": "Pas encore d'horaire",
+    "none": "Personne pour l'instant"
   }
 }

+ 8 - 2
messages/it/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Treno di pneumatici dismesso",
     "tire_set_attach": "File aggiunti a un treno di pneumatici",
     "tire_set_detach": "File rimosso da un treno di pneumatici",
-    "whatsapp_send": "Messaggio WhatsApp inviato"
+    "whatsapp_send": "Messaggio WhatsApp inviato",
+    "work_bay_create": "Postazione creata",
+    "work_bay_update": "Postazione aggiornata",
+    "work_bay_delete": "Postazione eliminata"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -256,6 +259,9 @@
     "vehicle_update": "Veicolo {name} aggiornato, modifiche: {fields}",
     "vehicle_updateNoChanges": "Veicolo {name} aggiornato, nessuna modifica",
     "webhook_create": "Webhook {name} creato",
-    "whatsapp_send": "Inviato un messaggio WhatsApp a {name}"
+    "whatsapp_send": "Inviato un messaggio WhatsApp a {name}",
+    "work_bay_create": "Postazione «{name}» creata",
+    "work_bay_update": "Postazione «{name}» aggiornata",
+    "work_bay_delete": "Postazione {id} eliminata"
   }
 }

+ 1 - 0
messages/it/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categoria",
       "quantity": "Quantità",
       "minQuantity": "Quantità min.",
+      "unit": "Unità",
       "unitCost": "Costo",
       "sellPrice": "Prezzo",
       "supplier": "Fornitore"

+ 8 - 0
messages/it/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Scaffale A-3",
     "quantity": "Quantità",
     "minQty": "Qtà min.",
+    "unit": "Unità",
+    "unitPlaceholder": "pz, l, kg...",
+    "unitSuggestions": "pz,set",
+    "unitSearch": "Cerca o digita...",
+    "unitCreate": "Usa \"{name}\"",
+    "unitGroupCount": "Conteggio",
+    "unitGroupMetric": "Metrico",
+    "unitGroupImperial": "Imperiale",
     "unitCost": "Costo unitario",
     "sellPrice": "Prezzo di vendita",
     "supplier": "Fornitore",

+ 4 - 1
messages/it/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Scopri di più",
-    "setDefaults": "Valori predefiniti"
+    "setDefaults": "Valori predefiniti",
+    "workBay": "Postazione",
+    "selectWorkBay": "Seleziona postazione…",
+    "noWorkBay": "Nessuna postazione"
   },
   "notifications": {
     "title": "Notifiche al cliente",

+ 2 - 0
messages/it/settings.json

@@ -831,6 +831,8 @@
     "liters": "litri (L)",
     "miles": "miglia (mi)",
     "gallons": "galloni (gal)",
+    "defaultStockUnit": "Unità di magazzino predefinita",
+    "defaultStockUnitHint": "Precompilata per i nuovi ricambi a magazzino. Ogni ricambio può comunque usare la propria unità.",
     "saveWorkshop": "Salva impostazioni officina",
     "saved": "Impostazioni officina salvate",
     "assignDialogTitle": "Assegnare agli ordini di lavoro esistenti?",

+ 79 - 4
messages/it/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Colonne",
+    "byTechnician": "Una colonna per tecnico",
+    "byBay": "Una colonna per postazione",
+    "byNothing": "Una colonna per giorno",
+    "weekends": "Fine settimana",
+    "add": "Aggiungi",
+    "addBay": "Aggiungi postazione",
+    "weekNumber": "Sett. {number}",
+    "lanes": "Colonne",
+    "showAllLanes": "Mostra tutte",
+    "onlyBusyLanes": "Solo con lavoro",
+    "layout": "Vista",
+    "layoutTimeline": "Cronologia",
+    "layoutCards": "Panoramica",
+    "rows": "Righe",
+    "rowPerTechnician": "Una riga per tecnico",
+    "rowPerBay": "Una riga per postazione",
+    "rowNothing": "Una sola riga",
+    "technicians": "Tecnici",
+    "workBays": "Postazioni",
+    "weekendsDayHint": "Vale solo quando è mostrata una settimana"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Impossibile spostare l'incarico",
     "failedAssign": "Impossibile assegnare il lavoro",
     "failedRemove": "Impossibile rimuovere l'incarico",
-    "createNewWorkorder": "Crea nuovo ordine di lavoro"
+    "createNewWorkorder": "Crea nuovo ordine di lavoro",
+    "noBays": "Nessuna postazione. Aggiungine una per pianificare la settimana per postazione.",
+    "addFirstBay": "Aggiungi postazione",
+    "lanes": {
+      "noBay": "Senza postazione",
+      "noTechnician": "Senza tecnico",
+      "everyone": "Tutto il lavoro",
+      "thisWeek": "Questa settimana"
+    },
+    "dropNeedsLane": "Raggruppa la lavagna per tecnico o postazione prima di trascinarci un lavoro."
   },
   "unassigned": {
     "title": "Lavori Non Assegnati ({count})",
@@ -62,7 +92,6 @@
     "status": "Stato",
     "week": "Settimana",
     "today": "Oggi",
-    "timeline": "Cronologia",
     "live": "Dal Vivo",
     "disconnected": "Aggiornamenti dal vivo disconnessi",
     "noTechnicians": "Nessun tecnico configurato",
@@ -76,6 +105,52 @@
       "completed": "Completato"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "La lavagna di stato raggruppa per stato",
+    "connecting": "Connessione…"
+  },
+  "week": {
+    "createAt": "Nuovo ordine di lavoro alle {time}",
+    "unscheduled": "Non pianificato ({count})",
+    "hint": "Trascina un lavoro per spostarlo. Trascina il bordo superiore o inferiore per cambiarne la durata.",
+    "hiddenDays": "{count} pianificato/i nel fine settimana. Mostra il fine settimana.",
+    "panLeft": "Prima nella settimana",
+    "panRight": "Più avanti nella settimana",
+    "hintPan": "Trascina lo sfondo per scorrere la settimana.",
+    "wholeWeek": "Vedi tutta la settimana",
+    "hintZoom": "Tieni premuto Ctrl e scorri per ingrandire."
+  },
+  "bay": {
+    "addTitle": "Aggiungi postazione",
+    "editTitle": "Modifica postazione",
+    "description": "Un luogo dove si lavora: un ponte, una rampa, un'area di lavaggio.",
+    "name": "Nome",
+    "namePlaceholder": "es. Ponte 1",
+    "color": "Colore",
+    "dailyCapacity": "Ore prenotabili al giorno",
+    "hoursPerDay": "ore al giorno",
+    "capacityHint": "Serve a mostrare quanto è pieno ogni giorno.",
+    "deleteTitle": "Elimina postazione",
+    "deleteDescription": "Rimuovere «{name}»? I lavori prenotati restano pianificati e passano alla colonna senza postazione.",
+    "addBay": "Aggiungi postazione",
+    "saveFailed": "Impossibile salvare la postazione"
+  },
+  "lane": {
+    "booked": "Pianificato",
+    "capacity": "Capacità",
+    "free": "Libero",
+    "over": "Eccedenza di",
+    "jobs": "Lavori",
+    "utilisation": "{pct}% della capacità",
+    "placeholder": "Lavoro non ancora assegnato qui."
+  },
+  "job": {
+    "time": "Orario",
+    "duration": "Durata",
+    "technician": "Tecnico",
+    "workBay": "Postazione",
+    "status": "Stato",
+    "noTime": "Nessun orario ancora",
+    "none": "Ancora nessuno"
   }
 }

+ 8 - 2
messages/lt/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Padangų komplektas nurašytas",
     "tire_set_attach": "Prie padangų komplekto pridėta failų",
     "tire_set_detach": "Failas pašalintas iš padangų komplekto",
-    "whatsapp_send": "Išsiųsta „WhatsApp“ žinutė"
+    "whatsapp_send": "Išsiųsta „WhatsApp“ žinutė",
+    "work_bay_create": "Sukurta darbo vieta",
+    "work_bay_update": "Atnaujinta darbo vieta",
+    "work_bay_delete": "Ištrinta darbo vieta"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -256,6 +259,9 @@
     "vehicle_update": "Atnaujinta transporto priemonė {name}, pakeista: {fields}",
     "vehicle_updateNoChanges": "Atnaujinta transporto priemonė {name}, niekas nepasikeitė",
     "webhook_create": "Sukurtas webhook {name}",
-    "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė"
+    "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė",
+    "work_bay_create": "Sukurta darbo vieta „{name}“",
+    "work_bay_update": "Atnaujinta darbo vieta „{name}“",
+    "work_bay_delete": "Ištrinta darbo vieta {id}"
   }
 }

+ 1 - 0
messages/lt/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategorija",
       "quantity": "Kiekis",
       "minQuantity": "Min. kiekis",
+      "unit": "Vienetas",
       "unitCost": "Savikaina",
       "sellPrice": "Kaina",
       "supplier": "Tiekėjas"

+ 8 - 0
messages/lt/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Lentyna A-3",
     "quantity": "Kiekis",
     "minQty": "Min. kiekis",
+    "unit": "Vienetas",
+    "unitPlaceholder": "vnt., l, kg...",
+    "unitSuggestions": "vnt.,kompl.",
+    "unitSearch": "Ieškoti arba įvesti...",
+    "unitCreate": "Naudoti \"{name}\"",
+    "unitGroupCount": "Vienetiniai",
+    "unitGroupMetric": "Metriniai",
+    "unitGroupImperial": "Imperiniai",
     "unitCost": "Vnt. savikaina",
     "sellPrice": "Pardavimo kaina",
     "supplier": "Tiekėjas",

+ 4 - 1
messages/lt/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platformos naudotojai",
     "customTechnicians": "Pasirinktiniai",
     "readMore": "Skaityti daugiau",
-    "setDefaults": "Nustatyti numatytuosius"
+    "setDefaults": "Nustatyti numatytuosius",
+    "workBay": "Darbo vieta",
+    "selectWorkBay": "Pasirinkite darbo vietą…",
+    "noWorkBay": "Be darbo vietos"
   },
   "notifications": {
     "title": "Kliento pranešimai",

+ 2 - 0
messages/lt/settings.json

@@ -831,6 +831,8 @@
     "liters": "litrai (l)",
     "miles": "mylios (mi)",
     "gallons": "galonai (gal)",
+    "defaultStockUnit": "Numatytasis sandėlio vienetas",
+    "defaultStockUnitHint": "Automatiškai įrašomas naujoms sandėlio dalims. Kiekviena dalis vis tiek gali turėti savo vienetą.",
     "saveWorkshop": "Išsaugoti dirbtuvių nustatymus",
     "saved": "Dirbtuvių nustatymai išsaugoti",
     "assignDialogTitle": "Priskirti esamiems darbo užsakymams?",

+ 79 - 4
messages/lt/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Stulpeliai",
+    "byTechnician": "Stulpelis kiekvienam meistrui",
+    "byBay": "Stulpelis kiekvienai darbo vietai",
+    "byNothing": "Stulpelis kiekvienai dienai",
+    "weekends": "Savaitgalis",
+    "add": "Pridėti",
+    "addBay": "Pridėti darbo vietą",
+    "weekNumber": "{number} sav.",
+    "lanes": "Stulpeliai",
+    "showAllLanes": "Rodyti visus",
+    "onlyBusyLanes": "Tik su darbais",
+    "layout": "Rodinys",
+    "layoutTimeline": "Laiko juosta",
+    "layoutCards": "Apžvalga",
+    "rows": "Eilutės",
+    "rowPerTechnician": "Eilutė kiekvienam meistrui",
+    "rowPerBay": "Eilutė kiekvienai darbo vietai",
+    "rowNothing": "Viena eilutė",
+    "technicians": "Meistrai",
+    "workBays": "Darbo vietos",
+    "weekendsDayHint": "Taikoma tik rodant savaitę"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Nepavyko perkelti priskyrimo",
     "failedAssign": "Nepavyko priskirti darbo",
     "failedRemove": "Nepavyko pašalinti priskyrimo",
-    "createNewWorkorder": "Sukurti naują darbo užsakymą"
+    "createNewWorkorder": "Sukurti naują darbo užsakymą",
+    "noBays": "Darbo vietų dar nėra. Pridėkite bent vieną, kad planuotumėte savaitę pagal darbo vietas.",
+    "addFirstBay": "Pridėti darbo vietą",
+    "lanes": {
+      "noBay": "Be darbo vietos",
+      "noTechnician": "Be meistro",
+      "everyone": "Visi darbai",
+      "thisWeek": "Ši savaitė"
+    },
+    "dropNeedsLane": "Prieš vilkdami darbą, sugrupuokite lentą pagal meistrą arba darbo vietą."
   },
   "unassigned": {
     "title": "Nepriskirti darbai ({count})",
@@ -62,7 +92,6 @@
     "status": "Būsena",
     "week": "Savaitė",
     "today": "Šiandien",
-    "timeline": "Laiko juosta",
     "live": "Tiesiogiai",
     "disconnected": "Tiesioginiai atnaujinimai atjungti",
     "noTechnicians": "Technikai nesukonfigūruoti",
@@ -76,6 +105,52 @@
       "completed": "Užbaigta"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Būsenų lenta grupuoja pagal būseną",
+    "connecting": "Jungiamasi…"
+  },
+  "week": {
+    "createAt": "Naujas darbo užsakymas {time}",
+    "unscheduled": "Nesuplanuota ({count})",
+    "hint": "Vilkite darbą, kad jį perkeltumėte. Vilkite viršutinį arba apatinį kraštą, kad pakeistumėte trukmę.",
+    "hiddenDays": "{count} suplanuota savaitgalį. Rodyti savaitgalį.",
+    "panLeft": "Anksčiau savaitėje",
+    "panRight": "Vėliau savaitėje",
+    "hintPan": "Vilkite foną, kad judėtumėte per savaitę.",
+    "wholeWeek": "Rodyti visą savaitę",
+    "hintZoom": "Laikykite Ctrl ir slinkite, kad priartintumėte."
+  },
+  "bay": {
+    "addTitle": "Pridėti darbo vietą",
+    "editTitle": "Redaguoti darbo vietą",
+    "description": "Vieta, kurioje atliekami darbai: keltuvas, estakada ar plovykla.",
+    "name": "Pavadinimas",
+    "namePlaceholder": "pvz. Keltuvas 1",
+    "color": "Spalva",
+    "dailyCapacity": "Rezervuojamos valandos per dieną",
+    "hoursPerDay": "val. per dieną",
+    "capacityHint": "Rodo, kiek užpildyta kiekviena diena.",
+    "deleteTitle": "Ištrinti darbo vietą",
+    "deleteDescription": "Pašalinti „{name}“? Jai priskirti darbai lieka suplanuoti ir persikelia į stulpelį be darbo vietos.",
+    "addBay": "Pridėti darbo vietą",
+    "saveFailed": "Nepavyko išsaugoti darbo vietos"
+  },
+  "lane": {
+    "booked": "Suplanuota",
+    "capacity": "Pajėgumas",
+    "free": "Laisva",
+    "over": "Viršyta",
+    "jobs": "Darbai",
+    "utilisation": "{pct} % pajėgumo",
+    "placeholder": "Darbai, kurie dar niekam nepriskirti."
+  },
+  "job": {
+    "time": "Laikas",
+    "duration": "Trukmė",
+    "technician": "Meistras",
+    "workBay": "Darbo vieta",
+    "status": "Būsena",
+    "noTime": "Laikas dar nenustatytas",
+    "none": "Kol kas niekas"
   }
 }

+ 8 - 2
messages/nb/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Kasserte et dekksett",
     "tire_set_attach": "La til filer på et dekksett",
     "tire_set_detach": "Fjernet en fil fra et dekksett",
-    "whatsapp_send": "WhatsApp-melding sendt"
+    "whatsapp_send": "WhatsApp-melding sendt",
+    "work_bay_create": "Opprettet arbeidsplass",
+    "work_bay_update": "Oppdaterte arbeidsplass",
+    "work_bay_delete": "Slettet arbeidsplass"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -256,6 +259,9 @@
     "vehicle_update": "Oppdaterte kjøretøy {name}, endret: {fields}",
     "vehicle_updateNoChanges": "Oppdaterte kjøretøy {name}, ingenting endret",
     "webhook_create": "Opprettet webhook {name}",
-    "whatsapp_send": "Sendte en WhatsApp-melding til {name}"
+    "whatsapp_send": "Sendte en WhatsApp-melding til {name}",
+    "work_bay_create": "Opprettet arbeidsplass «{name}»",
+    "work_bay_update": "Oppdaterte arbeidsplass «{name}»",
+    "work_bay_delete": "Slettet arbeidsplass {id}"
   }
 }

+ 1 - 0
messages/nb/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategori",
       "quantity": "Antall",
       "minQuantity": "Min. antall",
+      "unit": "Enhet",
       "unitCost": "Kostnad",
       "sellPrice": "Pris",
       "supplier": "Leverandør"

+ 8 - 0
messages/nb/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Hylle A-3",
     "quantity": "Antall",
     "minQty": "Min. antall",
+    "unit": "Enhet",
+    "unitPlaceholder": "stk, l, kg...",
+    "unitSuggestions": "stk,sett",
+    "unitSearch": "Søk eller skriv...",
+    "unitCreate": "Bruk \"{name}\"",
+    "unitGroupCount": "Antall",
+    "unitGroupMetric": "Metrisk",
+    "unitGroupImperial": "Imperisk",
     "unitCost": "Enhetskostnad",
     "sellPrice": "Salgspris",
     "supplier": "Leverandør",

+ 4 - 1
messages/nb/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Les mer",
-    "setDefaults": "Standardinnstillinger"
+    "setDefaults": "Standardinnstillinger",
+    "workBay": "Arbeidsplass",
+    "selectWorkBay": "Velg arbeidsplass …",
+    "noWorkBay": "Ingen arbeidsplass"
   },
   "notifications": {
     "title": "Kundevarsler",

+ 2 - 0
messages/nb/settings.json

@@ -831,6 +831,8 @@
     "liters": "liter (L)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Standard lagerenhet",
+    "defaultStockUnitHint": "Fylles ut automatisk for nye lagerdeler. Hver del kan fortsatt ha sin egen enhet.",
     "saveWorkshop": "Lagre verkstedinnstillinger",
     "saved": "Verkstedinnstillinger lagret",
     "assignDialogTitle": "Tildel til eksisterende arbeidsordre?",

+ 79 - 4
messages/nb/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Kolonner",
+    "byTechnician": "Én kolonne per mekaniker",
+    "byBay": "Én kolonne per arbeidsplass",
+    "byNothing": "Én kolonne per dag",
+    "weekends": "Helg",
+    "add": "Legg til",
+    "addBay": "Legg til arbeidsplass",
+    "weekNumber": "Uke {number}",
+    "lanes": "Kolonner",
+    "showAllLanes": "Vis alle",
+    "onlyBusyLanes": "Bare med jobber",
+    "layout": "Visning",
+    "layoutTimeline": "Tidslinje",
+    "layoutCards": "Oversikt",
+    "rows": "Rader",
+    "rowPerTechnician": "Én rad per mekaniker",
+    "rowPerBay": "Én rad per arbeidsplass",
+    "rowNothing": "Én enkelt rad",
+    "technicians": "Mekanikere",
+    "workBays": "Arbeidsplasser",
+    "weekendsDayHint": "Gjelder bare når en uke vises"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Kunne ikke flytte tilordning",
     "failedAssign": "Kunne ikke tilordne jobb",
     "failedRemove": "Kunne ikke fjerne tilordning",
-    "createNewWorkorder": "Opprett ny arbeidsordre"
+    "createNewWorkorder": "Opprett ny arbeidsordre",
+    "noBays": "Ingen arbeidsplasser ennå. Legg til én for å planlegge uken per plass.",
+    "addFirstBay": "Legg til arbeidsplass",
+    "lanes": {
+      "noBay": "Uten plass",
+      "noTechnician": "Uten mekaniker",
+      "everyone": "Alt arbeid",
+      "thisWeek": "Denne uken"
+    },
+    "dropNeedsLane": "Grupper tavlen etter mekaniker eller arbeidsplass før du slipper en jobb på den."
   },
   "unassigned": {
     "title": "Utildelte jobber ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "week": "Uke",
     "today": "I dag",
-    "timeline": "Tidslinje",
     "live": "Direkte",
     "disconnected": "Direkteoppdateringer frakoblet",
     "noTechnicians": "Ingen teknikere konfigurert",
@@ -76,6 +105,52 @@
       "completed": "Fullført"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Statustavlen grupperer etter status",
+    "connecting": "Kobler til …"
+  },
+  "week": {
+    "createAt": "Ny arbeidsordre kl. {time}",
+    "unscheduled": "Ikke planlagt ({count})",
+    "hint": "Dra en jobb for å flytte den. Dra øverste eller nederste kant for å endre varigheten.",
+    "hiddenDays": "{count} planlagt i helgen. Vis helg.",
+    "panLeft": "Tidligere i uken",
+    "panRight": "Senere i uken",
+    "hintPan": "Dra bakgrunnen for å bevege deg gjennom uken.",
+    "wholeWeek": "Vis hele uken",
+    "hintZoom": "Hold Ctrl og rull for å zoome."
+  },
+  "bay": {
+    "addTitle": "Legg til arbeidsplass",
+    "editTitle": "Rediger arbeidsplass",
+    "description": "Et sted arbeidet skjer: en løftebukk, en rampe eller en vaskeplass.",
+    "name": "Navn",
+    "namePlaceholder": "f.eks. Løftebukk 1",
+    "color": "Farge",
+    "dailyCapacity": "Bookbare timer per dag",
+    "hoursPerDay": "timer per dag",
+    "capacityHint": "Brukes til å vise hvor full hver dag er.",
+    "deleteTitle": "Slett arbeidsplass",
+    "deleteDescription": "Fjerne «{name}»? Jobber som er booket der forblir planlagt og flyttes til kolonnen uten plass.",
+    "addBay": "Legg til arbeidsplass",
+    "saveFailed": "Kunne ikke lagre arbeidsplassen"
+  },
+  "lane": {
+    "booked": "Booket",
+    "capacity": "Kapasitet",
+    "free": "Ledig",
+    "over": "Overbooket med",
+    "jobs": "Jobber",
+    "utilisation": "{pct} % av kapasiteten",
+    "placeholder": "Arbeid som ennå ikke er tildelt her."
+  },
+  "job": {
+    "time": "Tid",
+    "duration": "Varighet",
+    "technician": "Mekaniker",
+    "workBay": "Arbeidsplass",
+    "status": "Status",
+    "noTime": "Ingen tid satt ennå",
+    "none": "Ingen ennå"
   }
 }

+ 8 - 2
messages/nl/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Bandenset afgeschreven",
     "tire_set_attach": "Bestanden aan een bandenset toegevoegd",
     "tire_set_detach": "Bestand van een bandenset verwijderd",
-    "whatsapp_send": "WhatsApp-bericht verzonden"
+    "whatsapp_send": "WhatsApp-bericht verzonden",
+    "work_bay_create": "Werkplek aangemaakt",
+    "work_bay_update": "Werkplek bijgewerkt",
+    "work_bay_delete": "Werkplek verwijderd"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -256,6 +259,9 @@
     "vehicle_update": "Voertuig {name} bijgewerkt, gewijzigd: {fields}",
     "vehicle_updateNoChanges": "Voertuig {name} bijgewerkt, niets gewijzigd",
     "webhook_create": "Webhook {name} aangemaakt",
-    "whatsapp_send": "WhatsApp-bericht verzonden naar {name}"
+    "whatsapp_send": "WhatsApp-bericht verzonden naar {name}",
+    "work_bay_create": "Werkplek \"{name}\" aangemaakt",
+    "work_bay_update": "Werkplek \"{name}\" bijgewerkt",
+    "work_bay_delete": "Werkplek {id} verwijderd"
   }
 }

+ 1 - 0
messages/nl/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categorie",
       "quantity": "Aantal",
       "minQuantity": "Min. aantal",
+      "unit": "Eenheid",
       "unitCost": "Kostprijs",
       "sellPrice": "Prijs",
       "supplier": "Leverancier"

+ 8 - 0
messages/nl/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Schap A-3",
     "quantity": "Aantal",
     "minQty": "Min. aantal",
+    "unit": "Eenheid",
+    "unitPlaceholder": "st, l, kg...",
+    "unitSuggestions": "st,set",
+    "unitSearch": "Zoek of typ...",
+    "unitCreate": "Gebruik \"{name}\"",
+    "unitGroupCount": "Aantal",
+    "unitGroupMetric": "Metrisch",
+    "unitGroupImperial": "Imperiaal",
     "unitCost": "Kostprijs",
     "sellPrice": "Verkoopprijs",
     "supplier": "Leverancier",

+ 4 - 1
messages/nl/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Meer lezen",
-    "setDefaults": "Standaardwaarden"
+    "setDefaults": "Standaardwaarden",
+    "workBay": "Werkplek",
+    "selectWorkBay": "Werkplek kiezen…",
+    "noWorkBay": "Geen werkplek"
   },
   "notifications": {
     "title": "Klantmeldingen",

+ 2 - 0
messages/nl/settings.json

@@ -831,6 +831,8 @@
     "liters": "liters (L)",
     "miles": "mijlen (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Standaard voorraadeenheid",
+    "defaultStockUnitHint": "Vooraf ingevuld bij nieuwe voorraadonderdelen. Elk onderdeel kan nog steeds een eigen eenheid gebruiken.",
     "saveWorkshop": "Werkplaatsinstellingen opslaan",
     "saved": "Werkplaatsinstellingen opgeslagen",
     "assignDialogTitle": "Toewijzen aan bestaande werkorders?",

+ 79 - 4
messages/nl/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Kolommen",
+    "byTechnician": "Een kolom per monteur",
+    "byBay": "Een kolom per werkplek",
+    "byNothing": "Een kolom per dag",
+    "weekends": "Weekend",
+    "add": "Toevoegen",
+    "addBay": "Werkplek toevoegen",
+    "weekNumber": "Week {number}",
+    "lanes": "Kolommen",
+    "showAllLanes": "Alles tonen",
+    "onlyBusyLanes": "Alleen met werk",
+    "layout": "Weergave",
+    "layoutTimeline": "Tijdlijn",
+    "layoutCards": "Overzicht",
+    "rows": "Rijen",
+    "rowPerTechnician": "Een rij per monteur",
+    "rowPerBay": "Een rij per werkplek",
+    "rowNothing": "Eén enkele rij",
+    "technicians": "Monteurs",
+    "workBays": "Werkplekken",
+    "weekendsDayHint": "Geldt alleen bij een week"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Toewijzing verplaatsen mislukt",
     "failedAssign": "Taak toewijzen mislukt",
     "failedRemove": "Toewijzing verwijderen mislukt",
-    "createNewWorkorder": "Nieuwe werkorder aanmaken"
+    "createNewWorkorder": "Nieuwe werkorder aanmaken",
+    "noBays": "Nog geen werkplekken. Voeg er een toe om de week per werkplek te plannen.",
+    "addFirstBay": "Werkplek toevoegen",
+    "lanes": {
+      "noBay": "Zonder werkplek",
+      "noTechnician": "Zonder monteur",
+      "everyone": "Al het werk",
+      "thisWeek": "Deze week"
+    },
+    "dropNeedsLane": "Groepeer het bord op monteur of werkplek voordat je een klus erop sleept."
   },
   "unassigned": {
     "title": "Niet-toegewezen taken ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "week": "Week",
     "today": "Vandaag",
-    "timeline": "Tijdlijn",
     "live": "Live",
     "disconnected": "Live-updates verbroken",
     "noTechnicians": "Geen technici geconfigureerd",
@@ -76,6 +105,52 @@
       "completed": "Voltooid"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Het statusbord groepeert op status",
+    "connecting": "Verbinden…"
+  },
+  "week": {
+    "createAt": "Nieuwe werkorder om {time}",
+    "unscheduled": "Niet ingepland ({count})",
+    "hint": "Sleep een klus om die te verplaatsen. Sleep de boven- of onderrand om de duur te wijzigen.",
+    "hiddenDays": "{count} ingepland in het weekend. Weekend tonen.",
+    "panLeft": "Eerder in de week",
+    "panRight": "Later in de week",
+    "hintPan": "Sleep de achtergrond om door de week te bewegen.",
+    "wholeWeek": "Hele week tonen",
+    "hintZoom": "Houd Ctrl ingedrukt en scroll om te zoomen."
+  },
+  "bay": {
+    "addTitle": "Werkplek toevoegen",
+    "editTitle": "Werkplek bewerken",
+    "description": "Een plek waar gewerkt wordt: een brug, een oprit of een wasplaats.",
+    "name": "Naam",
+    "namePlaceholder": "bijv. Brug 1",
+    "color": "Kleur",
+    "dailyCapacity": "Boekbare uren per dag",
+    "hoursPerDay": "uur per dag",
+    "capacityHint": "Laat zien hoe vol elke dag is.",
+    "deleteTitle": "Werkplek verwijderen",
+    "deleteDescription": "\"{name}\" verwijderen? Klussen die erop staan blijven ingepland en gaan naar de kolom zonder werkplek.",
+    "addBay": "Werkplek toevoegen",
+    "saveFailed": "Kon de werkplek niet opslaan"
+  },
+  "lane": {
+    "booked": "Ingepland",
+    "capacity": "Capaciteit",
+    "free": "Vrij",
+    "over": "Over met",
+    "jobs": "Klussen",
+    "utilisation": "{pct}% van de capaciteit",
+    "placeholder": "Werk dat hier nog niet is toegewezen."
+  },
+  "job": {
+    "time": "Tijd",
+    "duration": "Duur",
+    "technician": "Monteur",
+    "workBay": "Werkplek",
+    "status": "Status",
+    "noTime": "Nog geen tijd",
+    "none": "Nog niemand"
   }
 }

+ 8 - 2
messages/pl/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Spisano komplet opon",
     "tire_set_attach": "Dodano pliki do kompletu opon",
     "tire_set_detach": "Usunięto plik z kompletu opon",
-    "whatsapp_send": "Wysłano wiadomość WhatsApp"
+    "whatsapp_send": "Wysłano wiadomość WhatsApp",
+    "work_bay_create": "Utworzono stanowisko",
+    "work_bay_update": "Zaktualizowano stanowisko",
+    "work_bay_delete": "Usunięto stanowisko"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -256,6 +259,9 @@
     "vehicle_update": "Zaktualizowano pojazd {name}, zmieniono: {fields}",
     "vehicle_updateNoChanges": "Zaktualizowano pojazd {name}, nic nie zmieniono",
     "webhook_create": "Utworzono webhook {name}",
-    "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}"
+    "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}",
+    "work_bay_create": "Utworzono stanowisko „{name}”",
+    "work_bay_update": "Zaktualizowano stanowisko „{name}”",
+    "work_bay_delete": "Usunięto stanowisko {id}"
   }
 }

+ 1 - 0
messages/pl/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategoria",
       "quantity": "Ilość",
       "minQuantity": "Min. ilość",
+      "unit": "Jednostka",
       "unitCost": "Koszt",
       "sellPrice": "Cena",
       "supplier": "Dostawca"

+ 8 - 0
messages/pl/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Półka A-3",
     "quantity": "Ilość",
     "minQty": "Min. ilość",
+    "unit": "Jednostka",
+    "unitPlaceholder": "szt., l, kg...",
+    "unitSuggestions": "szt.,kpl.",
+    "unitSearch": "Szukaj lub wpisz...",
+    "unitCreate": "Użyj \"{name}\"",
+    "unitGroupCount": "Sztuki",
+    "unitGroupMetric": "Metryczne",
+    "unitGroupImperial": "Imperialne",
     "unitCost": "Koszt jedn.",
     "sellPrice": "Cena sprzedaży",
     "supplier": "Dostawca",

+ 4 - 1
messages/pl/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Czytaj więcej",
-    "setDefaults": "Ustaw domyślne"
+    "setDefaults": "Ustaw domyślne",
+    "workBay": "Stanowisko",
+    "selectWorkBay": "Wybierz stanowisko…",
+    "noWorkBay": "Bez stanowiska"
   },
   "notifications": {
     "title": "Powiadomienia klienta",

+ 2 - 0
messages/pl/settings.json

@@ -831,6 +831,8 @@
     "liters": "litry (L)",
     "miles": "mile (mi)",
     "gallons": "galony (gal)",
+    "defaultStockUnit": "Domyślna jednostka magazynowa",
+    "defaultStockUnitHint": "Wstępnie uzupełniana dla nowych części w magazynie. Każda część może nadal mieć własną jednostkę.",
     "saveWorkshop": "Zapisz ustawienia warsztatu",
     "saved": "Ustawienia warsztatu zapisane",
     "assignDialogTitle": "Przypisać do istniejących zleceń?",

+ 79 - 4
messages/pl/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Kolumny",
+    "byTechnician": "Kolumna na mechanika",
+    "byBay": "Kolumna na stanowisko",
+    "byNothing": "Kolumna na dzień",
+    "weekends": "Weekend",
+    "add": "Dodaj",
+    "addBay": "Dodaj stanowisko",
+    "weekNumber": "Tydz. {number}",
+    "lanes": "Kolumny",
+    "showAllLanes": "Pokaż wszystkie",
+    "onlyBusyLanes": "Tylko ze zleceniami",
+    "layout": "Widok",
+    "layoutTimeline": "Oś czasu",
+    "layoutCards": "Przegląd",
+    "rows": "Wiersze",
+    "rowPerTechnician": "Wiersz na mechanika",
+    "rowPerBay": "Wiersz na stanowisko",
+    "rowNothing": "Jeden wiersz",
+    "technicians": "Mechanicy",
+    "workBays": "Stanowiska",
+    "weekendsDayHint": "Dotyczy tylko widoku tygodnia"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Nie udało się przenieść przydzielenia",
     "failedAssign": "Nie udało się przydzielić zadania",
     "failedRemove": "Nie udało się usunąć przydzielenia",
-    "createNewWorkorder": "Utwórz nowe zlecenie pracy"
+    "createNewWorkorder": "Utwórz nowe zlecenie pracy",
+    "noBays": "Brak stanowisk. Dodaj jedno, aby planować tydzień według stanowisk.",
+    "addFirstBay": "Dodaj stanowisko",
+    "lanes": {
+      "noBay": "Bez stanowiska",
+      "noTechnician": "Bez mechanika",
+      "everyone": "Cała praca",
+      "thisWeek": "Ten tydzień"
+    },
+    "dropNeedsLane": "Pogrupuj tablicę według mechanika lub stanowiska, zanim przeciągniesz na nią zlecenie."
   },
   "unassigned": {
     "title": "Nieprzyzdzielone Zadania ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "week": "Tydzień",
     "today": "Dzisiaj",
-    "timeline": "Oś czasu",
     "live": "Na Żywo",
     "disconnected": "Aktualizacje na żywo rozłączone",
     "noTechnicians": "Brak skonfigurowanych techników",
@@ -76,6 +105,52 @@
       "completed": "Ukończone"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Tablica statusów grupuje według statusu",
+    "connecting": "Łączenie…"
+  },
+  "week": {
+    "createAt": "Nowe zlecenie o {time}",
+    "unscheduled": "Niezaplanowane ({count})",
+    "hint": "Przeciągnij zlecenie, aby je przenieść. Przeciągnij górną lub dolną krawędź, aby zmienić czas trwania.",
+    "hiddenDays": "{count} zaplanowano w weekend. Pokaż weekend.",
+    "panLeft": "Wcześniej w tygodniu",
+    "panRight": "Później w tygodniu",
+    "hintPan": "Przeciągnij tło, aby przesuwać się po tygodniu.",
+    "wholeWeek": "Pokaż cały tydzień",
+    "hintZoom": "Przytrzymaj Ctrl i przewiń, aby przybliżyć."
+  },
+  "bay": {
+    "addTitle": "Dodaj stanowisko",
+    "editTitle": "Edytuj stanowisko",
+    "description": "Miejsce, w którym wykonywana jest praca: podnośnik, rampa lub myjnia.",
+    "name": "Nazwa",
+    "namePlaceholder": "np. Podnośnik 1",
+    "color": "Kolor",
+    "dailyCapacity": "Godziny do zaplanowania dziennie",
+    "hoursPerDay": "godzin dziennie",
+    "capacityHint": "Pokazuje, jak zapełniony jest każdy dzień.",
+    "deleteTitle": "Usuń stanowisko",
+    "deleteDescription": "Usunąć „{name}”? Zlecenia na tym stanowisku pozostaną zaplanowane i trafią do kolumny bez stanowiska.",
+    "addBay": "Dodaj stanowisko",
+    "saveFailed": "Nie udało się zapisać stanowiska"
+  },
+  "lane": {
+    "booked": "Zaplanowane",
+    "capacity": "Wydajność",
+    "free": "Wolne",
+    "over": "Przekroczono o",
+    "jobs": "Zlecenia",
+    "utilisation": "{pct}% wydajności",
+    "placeholder": "Praca jeszcze nieprzypisana."
+  },
+  "job": {
+    "time": "Godzina",
+    "duration": "Czas trwania",
+    "technician": "Mechanik",
+    "workBay": "Stanowisko",
+    "status": "Status",
+    "noTime": "Brak godziny",
+    "none": "Jeszcze nikt"
   }
 }

+ 8 - 2
messages/pt-BR/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Jogo de pneus baixado",
     "tire_set_attach": "Arquivos adicionados a um jogo de pneus",
     "tire_set_detach": "Arquivo removido de um jogo de pneus",
-    "whatsapp_send": "Mensagem de WhatsApp enviada"
+    "whatsapp_send": "Mensagem de WhatsApp enviada",
+    "work_bay_create": "Baia criada",
+    "work_bay_update": "Baia atualizada",
+    "work_bay_delete": "Baia excluída"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -256,6 +259,9 @@
     "vehicle_update": "Veículo {name} atualizado, alterado: {fields}",
     "vehicle_updateNoChanges": "Veículo {name} atualizado, nada alterado",
     "webhook_create": "Webhook {name} criado",
-    "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}"
+    "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}",
+    "work_bay_create": "Baia \"{name}\" criada",
+    "work_bay_update": "Baia \"{name}\" atualizada",
+    "work_bay_delete": "Baia {id} excluída"
   }
 }

+ 1 - 0
messages/pt-BR/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categoria",
       "quantity": "Quantidade",
       "minQuantity": "Qtd. mínima",
+      "unit": "Unidade",
       "unitCost": "Custo",
       "sellPrice": "Preço",
       "supplier": "Fornecedor"

+ 8 - 0
messages/pt-BR/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Prateleira A-3",
     "quantity": "Quantidade",
     "minQty": "Qtd. mín.",
+    "unit": "Unidade",
+    "unitPlaceholder": "pçs, l, kg...",
+    "unitSuggestions": "pçs,jogo",
+    "unitSearch": "Buscar ou digitar...",
+    "unitCreate": "Usar \"{name}\"",
+    "unitGroupCount": "Contagem",
+    "unitGroupMetric": "Métrico",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Custo unitário",
     "sellPrice": "Preço de venda",
     "supplier": "Fornecedor",

+ 4 - 1
messages/pt-BR/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Saiba mais",
-    "setDefaults": "Valores padrão"
+    "setDefaults": "Valores padrão",
+    "workBay": "Baia",
+    "selectWorkBay": "Selecionar baia…",
+    "noWorkBay": "Sem baia"
   },
   "notifications": {
     "title": "Notificações ao cliente",

+ 2 - 0
messages/pt-BR/settings.json

@@ -831,6 +831,8 @@
     "liters": "litros (L)",
     "miles": "milhas (mi)",
     "gallons": "galoes (gal)",
+    "defaultStockUnit": "Unidade de estoque padrão",
+    "defaultStockUnitHint": "Preenchida automaticamente em novas peças do estoque. Cada peça ainda pode usar sua própria unidade.",
     "saveWorkshop": "Salvar configurações da oficina",
     "saved": "Configurações da oficina salvas",
     "assignDialogTitle": "Atribuir às ordens de serviço existentes?",

+ 79 - 4
messages/pt-BR/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Colunas",
+    "byTechnician": "Uma coluna por técnico",
+    "byBay": "Uma coluna por baia",
+    "byNothing": "Uma coluna por dia",
+    "weekends": "Fim de semana",
+    "add": "Adicionar",
+    "addBay": "Adicionar baia",
+    "weekNumber": "Sem. {number}",
+    "lanes": "Colunas",
+    "showAllLanes": "Mostrar todas",
+    "onlyBusyLanes": "Somente com serviço",
+    "layout": "Exibição",
+    "layoutTimeline": "Linha do tempo",
+    "layoutCards": "Visão geral",
+    "rows": "Linhas",
+    "rowPerTechnician": "Uma linha por técnico",
+    "rowPerBay": "Uma linha por baia",
+    "rowNothing": "Uma única linha",
+    "technicians": "Técnicos",
+    "workBays": "Baias",
+    "weekendsDayHint": "Só se aplica ao ver uma semana"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Falha ao mover atribuição",
     "failedAssign": "Falha ao atribuir trabalho",
     "failedRemove": "Falha ao remover atribuição",
-    "createNewWorkorder": "Criar nova ordem de serviço"
+    "createNewWorkorder": "Criar nova ordem de serviço",
+    "noBays": "Nenhuma baia ainda. Adicione uma para planejar a semana por baia.",
+    "addFirstBay": "Adicionar baia",
+    "lanes": {
+      "noBay": "Sem baia",
+      "noTechnician": "Sem técnico",
+      "everyone": "Todo o trabalho",
+      "thisWeek": "Esta semana"
+    },
+    "dropNeedsLane": "Agrupe o quadro por técnico ou baia antes de soltar um serviço nele."
   },
   "unassigned": {
     "title": "Trabalhos Não Atribuídos ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "week": "Semana",
     "today": "Hoje",
-    "timeline": "Linha do tempo",
     "live": "Ao Vivo",
     "disconnected": "Atualizações ao vivo desconectadas",
     "noTechnicians": "Nenhum técnico configurado",
@@ -76,6 +105,52 @@
       "completed": "Concluído"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "O quadro de status agrupa por status",
+    "connecting": "Conectando…"
+  },
+  "week": {
+    "createAt": "Nova ordem de serviço às {time}",
+    "unscheduled": "Não agendado ({count})",
+    "hint": "Arraste um serviço para movê-lo. Arraste a borda superior ou inferior para mudar a duração.",
+    "hiddenDays": "{count} agendado(s) no fim de semana. Mostrar fim de semana.",
+    "panLeft": "Antes na semana",
+    "panRight": "Depois na semana",
+    "hintPan": "Arraste o fundo para percorrer a semana.",
+    "wholeWeek": "Ver a semana inteira",
+    "hintZoom": "Segure Ctrl e role para dar zoom."
+  },
+  "bay": {
+    "addTitle": "Adicionar baia",
+    "editTitle": "Editar baia",
+    "description": "Um lugar onde o trabalho acontece: um elevador, uma rampa ou uma área de lavagem.",
+    "name": "Nome",
+    "namePlaceholder": "ex.: Elevador 1",
+    "color": "Cor",
+    "dailyCapacity": "Horas agendáveis por dia",
+    "hoursPerDay": "horas por dia",
+    "capacityHint": "Mostra o quanto cada dia está cheio.",
+    "deleteTitle": "Excluir baia",
+    "deleteDescription": "Remover \"{name}\"? Os serviços agendados nela continuam agendados e vão para a coluna sem baia.",
+    "addBay": "Adicionar baia",
+    "saveFailed": "Não foi possível salvar a baia"
+  },
+  "lane": {
+    "booked": "Agendado",
+    "capacity": "Capacidade",
+    "free": "Livre",
+    "over": "Excedido em",
+    "jobs": "Serviços",
+    "utilisation": "{pct}% da capacidade",
+    "placeholder": "Serviço ainda sem atribuição aqui."
+  },
+  "job": {
+    "time": "Horário",
+    "duration": "Duração",
+    "technician": "Técnico",
+    "workBay": "Baia",
+    "status": "Status",
+    "noTime": "Ainda sem horário",
+    "none": "Ainda ninguém"
   }
 }

+ 8 - 2
messages/ru/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Комплект шин списан",
     "tire_set_attach": "Файлы добавлены к комплекту шин",
     "tire_set_detach": "Файл удалён из комплекта шин",
-    "whatsapp_send": "Отправлено сообщение WhatsApp"
+    "whatsapp_send": "Отправлено сообщение WhatsApp",
+    "work_bay_create": "Создан пост",
+    "work_bay_update": "Обновлён пост",
+    "work_bay_delete": "Удалён пост"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -256,6 +259,9 @@
     "vehicle_update": "Обновлён автомобиль {name}, изменено: {fields}",
     "vehicle_updateNoChanges": "Обновлён автомобиль {name}, изменений нет",
     "webhook_create": "Создан вебхук {name}",
-    "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}"
+    "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}",
+    "work_bay_create": "Создан пост «{name}»",
+    "work_bay_update": "Обновлён пост «{name}»",
+    "work_bay_delete": "Удалён пост {id}"
   }
 }

+ 1 - 0
messages/ru/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Категория",
       "quantity": "Кол-во",
       "minQuantity": "Мин. кол-во",
+      "unit": "Единица",
       "unitCost": "Себестоимость",
       "sellPrice": "Цена",
       "supplier": "Поставщик"

+ 8 - 0
messages/ru/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Полка А-3",
     "quantity": "Количество",
     "minQty": "Мин. кол-во",
+    "unit": "Единица",
+    "unitPlaceholder": "шт, л, кг...",
+    "unitSuggestions": "шт,компл",
+    "unitSearch": "Поиск или ввод...",
+    "unitCreate": "Использовать \"{name}\"",
+    "unitGroupCount": "Штучные",
+    "unitGroupMetric": "Метрические",
+    "unitGroupImperial": "Имперские",
     "unitCost": "Себестоимость",
     "sellPrice": "Цена продажи",
     "supplier": "Поставщик",

+ 4 - 1
messages/ru/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Подробнее",
-    "setDefaults": "Настройки по умолчанию"
+    "setDefaults": "Настройки по умолчанию",
+    "workBay": "Пост",
+    "selectWorkBay": "Выберите пост…",
+    "noWorkBay": "Без поста"
   },
   "notifications": {
     "title": "Уведомления клиента",

+ 2 - 0
messages/ru/settings.json

@@ -831,6 +831,8 @@
     "liters": "литры (л)",
     "miles": "мили (mi)",
     "gallons": "галлоны (gal)",
+    "defaultStockUnit": "Единица склада по умолчанию",
+    "defaultStockUnitHint": "Подставляется для новых складских позиций. У каждой позиции может быть своя единица.",
     "saveWorkshop": "Сохранить настройки мастерской",
     "saved": "Настройки мастерской сохранены",
     "assignDialogTitle": "Назначить на существующие заказы?",

+ 79 - 4
messages/ru/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Колонки",
+    "byTechnician": "Колонка на механика",
+    "byBay": "Колонка на пост",
+    "byNothing": "Колонка на день",
+    "weekends": "Выходные",
+    "add": "Добавить",
+    "addBay": "Добавить пост",
+    "weekNumber": "Нед. {number}",
+    "lanes": "Колонки",
+    "showAllLanes": "Показать все",
+    "onlyBusyLanes": "Только с работой",
+    "layout": "Вид",
+    "layoutTimeline": "Шкала времени",
+    "layoutCards": "Обзор",
+    "rows": "Строки",
+    "rowPerTechnician": "Строка на механика",
+    "rowPerBay": "Строка на пост",
+    "rowNothing": "Одна строка",
+    "technicians": "Механики",
+    "workBays": "Посты",
+    "weekendsDayHint": "Действует только при показе недели"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Не удалось переместить назначение",
     "failedAssign": "Не удалось назначить работу",
     "failedRemove": "Не удалось удалить назначение",
-    "createNewWorkorder": "Создать новый заказ-наряд"
+    "createNewWorkorder": "Создать новый заказ-наряд",
+    "noBays": "Постов пока нет. Добавьте пост, чтобы планировать неделю по постам.",
+    "addFirstBay": "Добавить пост",
+    "lanes": {
+      "noBay": "Без поста",
+      "noTechnician": "Без механика",
+      "everyone": "Вся работа",
+      "thisWeek": "Эта неделя"
+    },
+    "dropNeedsLane": "Сгруппируйте доску по механику или посту, прежде чем переносить на неё работу."
   },
   "unassigned": {
     "title": "Неназначенные работы ({count})",
@@ -62,7 +92,6 @@
     "status": "Статус",
     "week": "Неделя",
     "today": "Сегодня",
-    "timeline": "Хронология",
     "live": "Онлайн",
     "disconnected": "Обновления в реальном времени отключены",
     "noTechnicians": "Механики не настроены",
@@ -76,6 +105,52 @@
       "completed": "Завершено"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Доска статусов группирует по статусу",
+    "connecting": "Подключение…"
+  },
+  "week": {
+    "createAt": "Новый заказ-наряд в {time}",
+    "unscheduled": "Без времени ({count})",
+    "hint": "Перетащите работу, чтобы перенести её. Потяните верхний или нижний край, чтобы изменить длительность.",
+    "hiddenDays": "{count} запланировано на выходные. Показать выходные.",
+    "panLeft": "Раньше на неделе",
+    "panRight": "Позже на неделе",
+    "hintPan": "Перетащите фон, чтобы двигаться по неделе.",
+    "wholeWeek": "Показать всю неделю",
+    "hintZoom": "Удерживайте Ctrl и прокручивайте для масштаба."
+  },
+  "bay": {
+    "addTitle": "Добавить пост",
+    "editTitle": "Изменить пост",
+    "description": "Место, где выполняется работа: подъёмник, эстакада или мойка.",
+    "name": "Название",
+    "namePlaceholder": "напр. Подъёмник 1",
+    "color": "Цвет",
+    "dailyCapacity": "Часов в день под запись",
+    "hoursPerDay": "часов в день",
+    "capacityHint": "Показывает, насколько заполнен каждый день.",
+    "deleteTitle": "Удалить пост",
+    "deleteDescription": "Удалить «{name}»? Работы на нём останутся запланированными и перейдут в колонку без поста.",
+    "addBay": "Добавить пост",
+    "saveFailed": "Не удалось сохранить пост"
+  },
+  "lane": {
+    "booked": "Запланировано",
+    "capacity": "Ёмкость",
+    "free": "Свободно",
+    "over": "Превышение на",
+    "jobs": "Работы",
+    "utilisation": "{pct} % ёмкости",
+    "placeholder": "Работа, ещё никому не назначенная."
+  },
+  "job": {
+    "time": "Время",
+    "duration": "Длительность",
+    "technician": "Механик",
+    "workBay": "Пост",
+    "status": "Статус",
+    "noTime": "Время не назначено",
+    "none": "Пока никто"
   }
 }

+ 8 - 2
messages/tr/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Lastik takımı hurdaya ayrıldı",
     "tire_set_attach": "Lastik takımına dosya eklendi",
     "tire_set_detach": "Lastik takımından dosya kaldırıldı",
-    "whatsapp_send": "WhatsApp mesajı gönderildi"
+    "whatsapp_send": "WhatsApp mesajı gönderildi",
+    "work_bay_create": "Çalışma alanı oluşturuldu",
+    "work_bay_update": "Çalışma alanı güncellendi",
+    "work_bay_delete": "Çalışma alanı silindi"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -256,6 +259,9 @@
     "vehicle_update": "{name} aracı güncellendi, değişenler: {fields}",
     "vehicle_updateNoChanges": "{name} aracı güncellendi, hiçbir şey değişmedi",
     "webhook_create": "{name} webhook'u oluşturuldu",
-    "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi"
+    "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi",
+    "work_bay_create": "\"{name}\" çalışma alanı oluşturuldu",
+    "work_bay_update": "\"{name}\" çalışma alanı güncellendi",
+    "work_bay_delete": "{id} çalışma alanı silindi"
   }
 }

+ 1 - 0
messages/tr/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategori",
       "quantity": "Adet",
       "minQuantity": "Min. adet",
+      "unit": "Birim",
       "unitCost": "Maliyet",
       "sellPrice": "Fiyat",
       "supplier": "Tedarikçi"

+ 8 - 0
messages/tr/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Raf A-3",
     "quantity": "Miktar",
     "minQty": "Min. miktar",
+    "unit": "Birim",
+    "unitPlaceholder": "adet, l, kg...",
+    "unitSuggestions": "adet,takım",
+    "unitSearch": "Ara veya yaz...",
+    "unitCreate": "\"{name}\" kullan",
+    "unitGroupCount": "Adet",
+    "unitGroupMetric": "Metrik",
+    "unitGroupImperial": "İngiliz",
     "unitCost": "Birim maliyet",
     "sellPrice": "Satış fiyatı",
     "supplier": "Tedarikçi",

+ 4 - 1
messages/tr/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "readMore": "Daha fazla",
-    "setDefaults": "Varsayılanlar"
+    "setDefaults": "Varsayılanlar",
+    "workBay": "Çalışma alanı",
+    "selectWorkBay": "Çalışma alanı seçin…",
+    "noWorkBay": "Çalışma alanı yok"
   },
   "notifications": {
     "title": "Müşteri bildirimleri",

+ 2 - 0
messages/tr/settings.json

@@ -831,6 +831,8 @@
     "liters": "litre (L)",
     "miles": "mil (mi)",
     "gallons": "galon (gal)",
+    "defaultStockUnit": "Varsayılan stok birimi",
+    "defaultStockUnitHint": "Yeni stok parçalarında önceden doldurulur. Her parça yine de kendi birimini kullanabilir.",
     "saveWorkshop": "Atölye Ayarlarını Kaydet",
     "saved": "Atölye ayarları kaydedildi",
     "assignDialogTitle": "Mevcut iş emirlerine ata?",

+ 79 - 4
messages/tr/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Sütunlar",
+    "byTechnician": "Teknisyen başına bir sütun",
+    "byBay": "Çalışma alanı başına bir sütun",
+    "byNothing": "Gün başına bir sütun",
+    "weekends": "Hafta sonu",
+    "add": "Ekle",
+    "addBay": "Çalışma alanı ekle",
+    "weekNumber": "{number}. hafta",
+    "lanes": "Sütunlar",
+    "showAllLanes": "Tümünü göster",
+    "onlyBusyLanes": "Yalnızca işi olanlar",
+    "layout": "Görünüm",
+    "layoutTimeline": "Zaman çizelgesi",
+    "layoutCards": "Genel bakış",
+    "rows": "Satırlar",
+    "rowPerTechnician": "Teknisyen başına bir satır",
+    "rowPerBay": "Çalışma alanı başına bir satır",
+    "rowNothing": "Tek satır",
+    "technicians": "Teknisyenler",
+    "workBays": "Çalışma alanları",
+    "weekendsDayHint": "Yalnızca hafta görünümünde geçerlidir"
   },
   "board": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Atama taşınamadı",
     "failedAssign": "İş atanmadı",
     "failedRemove": "Atama kaldırılamadı",
-    "createNewWorkorder": "Yeni iş emri oluştur"
+    "createNewWorkorder": "Yeni iş emri oluştur",
+    "noBays": "Henüz çalışma alanı yok. Haftayı alana göre planlamak için bir tane ekleyin.",
+    "addFirstBay": "Çalışma alanı ekle",
+    "lanes": {
+      "noBay": "Alansız",
+      "noTechnician": "Teknisyensiz",
+      "everyone": "Tüm işler",
+      "thisWeek": "Bu hafta"
+    },
+    "dropNeedsLane": "Bir işi panoya bırakmadan önce panoyu teknisyene veya çalışma alanına göre gruplayın."
   },
   "unassigned": {
     "title": "Atanmamış İşler ({count})",
@@ -62,7 +92,6 @@
     "status": "Durum",
     "week": "Hafta",
     "today": "Bugün",
-    "timeline": "Zaman çizelgesi",
     "live": "Canlı",
     "disconnected": "Canlı güncellemeler kesildi",
     "noTechnicians": "Hiçbir teknisyen yapılandırılmadı",
@@ -76,6 +105,52 @@
       "completed": "Tamamlandı"
     },
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Durum panosu duruma göre gruplar",
+    "connecting": "Bağlanıyor…"
+  },
+  "week": {
+    "createAt": "{time} için yeni iş emri",
+    "unscheduled": "Planlanmamış ({count})",
+    "hint": "Bir işi taşımak için sürükleyin. Süresini değiştirmek için üst veya alt kenarını sürükleyin.",
+    "hiddenDays": "{count} iş hafta sonuna planlandı. Hafta sonunu göster.",
+    "panLeft": "Haftanın başına doğru",
+    "panRight": "Haftanın sonuna doğru",
+    "hintPan": "Hafta boyunca ilerlemek için arka planı sürükleyin.",
+    "wholeWeek": "Tüm haftayı gör",
+    "hintZoom": "Yakınlaştırmak için Ctrl tuşuna basılı tutup kaydırın."
+  },
+  "bay": {
+    "addTitle": "Çalışma alanı ekle",
+    "editTitle": "Çalışma alanını düzenle",
+    "description": "İşin yapıldığı yer: bir lift, bir rampa veya yıkama alanı.",
+    "name": "Ad",
+    "namePlaceholder": "örn. Lift 1",
+    "color": "Renk",
+    "dailyCapacity": "Günlük planlanabilir saat",
+    "hoursPerDay": "saat/gün",
+    "capacityHint": "Her günün ne kadar dolu olduğunu gösterir.",
+    "deleteTitle": "Çalışma alanını sil",
+    "deleteDescription": "\"{name}\" kaldırılsın mı? Buraya planlanan işler planlı kalır ve alansız sütuna geçer.",
+    "addBay": "Çalışma alanı ekle",
+    "saveFailed": "Çalışma alanı kaydedilemedi"
+  },
+  "lane": {
+    "booked": "Planlanan",
+    "capacity": "Kapasite",
+    "free": "Boş",
+    "over": "Aşım",
+    "jobs": "İşler",
+    "utilisation": "Kapasitenin %{pct}'i",
+    "placeholder": "Henüz buraya atanmamış işler."
+  },
+  "job": {
+    "time": "Saat",
+    "duration": "Süre",
+    "technician": "Teknisyen",
+    "workBay": "Çalışma alanı",
+    "status": "Durum",
+    "noTime": "Henüz saat yok",
+    "none": "Henüz kimse"
   }
 }

+ 38 - 0
prisma/migrations/20260825120000_work_bays/migration.sql

@@ -0,0 +1,38 @@
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "workBayId" TEXT;
+
+-- AlterTable
+ALTER TABLE "inspections" ADD COLUMN     "workBayId" TEXT;
+
+-- CreateTable
+CREATE TABLE "work_bays" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "color" TEXT NOT NULL DEFAULT '#64748b',
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "dailyCapacity" INTEGER NOT NULL DEFAULT 480,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "work_bays_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "work_bays_organizationId_idx" ON "work_bays"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "service_records_workBayId_idx" ON "service_records"("workBayId");
+
+-- CreateIndex
+CREATE INDEX "inspections_workBayId_idx" ON "inspections"("workBayId");
+
+-- AddForeignKey
+ALTER TABLE "work_bays" ADD CONSTRAINT "work_bays_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_workBayId_fkey" FOREIGN KEY ("workBayId") REFERENCES "work_bays"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "inspections" ADD CONSTRAINT "inspections_workBayId_fkey" FOREIGN KEY ("workBayId") REFERENCES "work_bays"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 23 - 0
prisma/migrations/20260826155804_inventory_units_fractional_stock/migration.sql

@@ -0,0 +1,23 @@
+-- AlterTable
+ALTER TABLE "inventory_parts" ADD COLUMN     "unit" TEXT,
+ALTER COLUMN "quantity" SET DEFAULT 0,
+ALTER COLUMN "quantity" SET DATA TYPE DOUBLE PRECISION,
+ALTER COLUMN "minQuantity" SET DEFAULT 0,
+ALTER COLUMN "minQuantity" SET DATA TYPE DOUBLE PRECISION;
+
+-- AlterTable
+ALTER TABLE "labor_preset_parts" ADD COLUMN     "unit" TEXT;
+
+-- AlterTable
+ALTER TABLE "quote_parts" ADD COLUMN     "unit" TEXT;
+
+-- AlterTable
+ALTER TABLE "recurring_parts" ALTER COLUMN "quantity" SET DEFAULT 1,
+ALTER COLUMN "quantity" SET DATA TYPE DOUBLE PRECISION;
+
+-- AlterTable
+ALTER TABLE "service_parts" ADD COLUMN     "unit" TEXT;
+
+-- AlterTable
+ALTER TABLE "stock_movements" ALTER COLUMN "delta" SET DATA TYPE DOUBLE PRECISION,
+ALTER COLUMN "quantityAfter" SET DATA TYPE DOUBLE PRECISION;

+ 83 - 30
prisma/schema.prisma

@@ -266,7 +266,13 @@ model ServiceRecord {
 
   technicianId String?
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-  sortOrder    Int         @default(0)
+
+  /// Where in the shop the job is booked in: a lift, a ramp, an alignment
+  /// stand. Independent of the technician, because a shop plans its bays and
+  /// its people separately and the work board can lane by either.
+  workBayId String?
+  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
+  sortOrder Int      @default(0)
 
   partItems      ServicePart[]
   laborItems     ServiceLabor[]
@@ -280,6 +286,7 @@ model ServiceRecord {
 
   @@index([vehicleId])
   @@index([technicianId])
+  @@index([workBayId])
   @@index([organizationId])
   @@index([customerId])
   @@index([inspectionId])
@@ -460,6 +467,9 @@ model ServicePart {
   partNumber      String?
   name            String
   quantity        Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time; null for
+  /// free-text lines and legacy rows.
+  unit            String?
   unitPrice       Float   @default(0)
   total           Float   @default(0)
   unitCost        Float   @default(0)
@@ -524,6 +534,8 @@ model LaborPresetPart {
   name            String
   partNumber      String?
   quantity        Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time.
+  unit            String?
   unitPrice       Float   @default(0)
   inventoryPartId String?
   sortOrder       Int     @default(0)
@@ -542,8 +554,12 @@ model InventoryPart {
   name          String
   description   String?
   category      String?
-  quantity      Int     @default(0)
-  minQuantity   Int     @default(0)
+  quantity      Float   @default(0)
+  minQuantity   Float   @default(0)
+  /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
+  /// snapshotted onto part lines at pick time so invoices keep the unit the
+  /// part had when it was billed.
+  unit          String?
   unitCost      Float   @default(0)
   sellPrice     Float   @default(0)
   supplier      String?
@@ -586,10 +602,11 @@ model InventoryPart {
 model StockMovement {
   id String @id @default(cuid())
 
-  /// Signed change in whole units. Negative = consumed, positive = restocked.
-  delta         Int
+  /// Signed change in stock units (fractional allowed, e.g. 0.5 l).
+  /// Negative = consumed, positive = restocked.
+  delta         Float
   /// Part balance immediately after this movement was applied.
-  quantityAfter Int
+  quantityAfter Float
   /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
   reason        String
   note          String?
@@ -718,6 +735,9 @@ model QuotePart {
   partNumber    String?
   name          String
   quantity      Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time; null for
+  /// free-text lines and legacy rows.
+  unit          String?
   /// Internal purchase cost — never shown to customers (quote PDF/share
   /// render unitPrice only)
   unitCost      Float   @default(0)
@@ -835,6 +855,7 @@ model Organization {
   telegramMessages       TelegramMessage[]
   whatsappMessages       WhatsappMessage[]
   technicians            Technician[]
+  workBays               WorkBay[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
@@ -1001,7 +1022,7 @@ model RecurringPart {
   id         String  @id @default(cuid())
   name       String
   partNumber String?
-  quantity   Int     @default(1)
+  quantity   Float   @default(1)
   unitPrice  Float   @default(0)
 
   recurringInvoiceId String
@@ -1129,7 +1150,12 @@ model Inspection {
 
   technicianId String?
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-  sortOrder    Int         @default(0)
+
+  /// See ServiceRecord.workBayId — an inspection occupies a bay just as a
+  /// repair does, and the work board lanes both the same way.
+  workBayId String?
+  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
+  sortOrder Int      @default(0)
 
   // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
   // `inspectorName` and `certificateNumber` are snapshots taken at completion so
@@ -1158,6 +1184,7 @@ model Inspection {
   @@index([vehicleId])
   @@index([organizationId])
   @@index([technicianId])
+  @@index([workBayId])
   @@map("inspections")
 }
 
@@ -1285,7 +1312,7 @@ model TelegramMessage {
 /// provider without its history becoming unreadable, and a status callback
 /// arriving later has to find the row it belongs to.
 model WhatsappMessage {
-  id        String  @id @default(cuid())
+  id        String @id @default(cuid())
   direction String // "inbound" | "outbound"
   provider  String // "meta" | "twilio" | any adapter registered later
 
@@ -1327,7 +1354,6 @@ model WhatsappMessage {
   @@map("whatsapp_messages")
 }
 
-
 model Technician {
   id            String  @id @default(cuid())
   name          String
@@ -1353,6 +1379,35 @@ model Technician {
   @@map("technicians")
 }
 
+/// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
+/// plan by bay as often as by person — the bay is the scarce resource, and a
+/// technician moves between them during a day. The work board can group its
+/// lanes by either, so both are first-class.
+model WorkBay {
+  id            String  @id @default(cuid())
+  name          String
+  color         String  @default("#64748b")
+  isActive      Boolean @default(true)
+  /// Ordering on the board, left to right. Shops name bays by position
+  /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
+  sortOrder     Int     @default(0)
+  /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
+  /// can show utilisation for whichever lane grouping is on.
+  dailyCapacity Int     @default(480)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  serviceRecords ServiceRecord[]
+  inspections    Inspection[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId])
+  @@map("work_bays")
+}
+
 model AuditLog {
   id        String   @id @default(cuid())
   timestamp DateTime @default(now())
@@ -1646,12 +1701,12 @@ model WebhookDelivery {
 /// A physical building or room holding tire storage. Most workshops have one;
 /// multi-site operators have several.
 model TireWarehouse {
-  id      String  @id @default(cuid())
-  name    String
-  address String?
-  notes   String?
+  id         String  @id @default(cuid())
+  name       String
+  address    String?
+  notes      String?
   /// Preselected when checking tires in. Exactly one per organization.
-  isDefault Boolean @default(false)
+  isDefault  Boolean @default(false)
   isArchived Boolean @default(false)
 
   organizationId String
@@ -1721,25 +1776,25 @@ model TireSet {
 
   /// summer | winter | all_season | other — see TIRE_SEASONS in
   /// src/features/tire-hotel/Lib/tireConstants.ts
-  season String @default("summer")
+  season  String  @default("summer")
   /// Studded winter tires are legal in some markets and banned in others, so
   /// this is a flag on the season rather than a season of its own.
   studded Boolean @default(false)
 
-  brand String?
-  model String?
+  brand          String?
+  model          String?
   /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
   /// "31x10.5R15".
-  size String?
+  size           String?
   /// DOT / TIN week-year code used to judge tire age.
-  dotCode String?
+  dotCode        String?
   loadSpeedIndex String?
 
   /// Mounted on rims, which changes both handling and what a shelf holds.
   withRims Boolean @default(false)
   rimType  String?
   /// Tire pressure monitoring sensors present — they need care on refit.
-  hasTpms Boolean @default(false)
+  hasTpms  Boolean @default(false)
 
   /// Individual tires in this set. Drives every capacity calculation.
   quantity Int @default(4)
@@ -1768,11 +1823,11 @@ model TireSet {
   checkedInAt  DateTime?
   checkedOutAt DateTime?
 
-  measurements TireMeasurement[]
-  movements    TireMovement[]
-  treatments   TireTreatment[]
-  attachments  TireSetAttachment[]
-  quotes       Quote[]
+  measurements   TireMeasurement[]
+  movements      TireMovement[]
+  treatments     TireTreatment[]
+  attachments    TireSetAttachment[]
+  quotes         Quote[]
   serviceRecords ServiceRecord[]
 
   createdAt DateTime @default(now())
@@ -1840,10 +1895,10 @@ model TireMeasurement {
   /// every market.
   treadDepthMm Float?
   /// Always stored in bar. Converted to psi/kPa for display.
-  pressureBar Float?
+  pressureBar  Float?
 
   /// good | fair | replace — see TIRE_CONDITIONS
-  condition String @default("good")
+  condition String  @default("good")
   damage    String?
   notes     String?
 
@@ -1907,8 +1962,6 @@ model TireMovement {
   @@map("tire_movements")
 }
 
-
-
 /// Work a set needs before it goes back on the car, or before it goes on the
 /// shelf: washing, balancing, a TPMS service.
 ///

+ 74 - 0
src/__tests__/features/backup/part-restore-fields.test.ts

@@ -0,0 +1,74 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Restore completeness for part lines.
+ *
+ * The import route rebuilds ServicePart and QuotePart rows through explicit
+ * field maps. A column added to the schema but not to the map is dropped
+ * silently on restore, and the loss only surfaces later — zeroed profit
+ * reports, dead inventory links. Like the tire hotel coverage test, this
+ * reads the route source and the schema rather than running the route: the
+ * failure being guarded against is "someone forgot a field", which is visible
+ * in the source.
+ */
+
+const ROOT = process.cwd()
+const importSource = fs.readFileSync(
+  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
+  'utf-8'
+)
+const schema = fs.readFileSync(path.join(ROOT, 'prisma/schema.prisma'), 'utf-8')
+
+/** Scalar (persisted, non-relation) columns of a model, from the schema. */
+function scalarColumns(model: string): string[] {
+  const match = schema.match(new RegExp(`^model\\s+${model}\\s*\\{([\\s\\S]*?)^\\}`, 'm'))
+  expect(match, `model ${model} not found in schema`).toBeTruthy()
+  const columns: string[] = []
+  for (const line of match![1].split('\n')) {
+    const field = line.match(/^\s{2}(\w+)\s+(String|Int|Float|Boolean|DateTime|Json)(\?|\[\])?/)
+    if (field) columns.push(field[1])
+  }
+  expect(columns.length).toBeGreaterThan(0)
+  return columns
+}
+
+/** The restore map for one model — the createMany call and its data mapper. */
+function restoreMap(delegate: string): string {
+  const marker = `tx.${delegate}.createMany`
+  const start = importSource.indexOf(marker)
+  expect(start, `${marker} not found in import route`).toBeGreaterThan(-1)
+  // One restore site per model; a second one would need its own coverage.
+  expect(importSource.indexOf(marker, start + 1)).toBe(-1)
+  return importSource.slice(start, start + 1500)
+}
+
+describe.each([
+  { model: 'ServicePart', delegate: 'servicePart' },
+  { model: 'QuotePart', delegate: 'quotePart' },
+])('$model restore', ({ model, delegate }) => {
+  it('carries every schema column, so nothing is dropped on restore', () => {
+    const map = restoreMap(delegate)
+    for (const column of scalarColumns(model)) {
+      expect(map, `${model}.${column} is lost by the ${delegate} restore map`).toMatch(
+        new RegExp(`\\b${column}:`)
+      )
+    }
+  })
+})
+
+describe('restore ordering', () => {
+  it('restores inventory parts before the lines that reference them', () => {
+    // inventoryPartId is restored verbatim, which is only honest if the
+    // inventory rows (with their preserved ids) already exist by then.
+    // Service parts restore inside the importServiceRecordTree helper, so the
+    // execution order is the order of the call sites, not of the definitions.
+    const inventory = importSource.indexOf('tx.inventoryPart.createMany')
+    const firstTreeCall = importSource.indexOf('await importServiceRecordTree(')
+    expect(inventory).toBeGreaterThan(-1)
+    expect(firstTreeCall).toBeGreaterThan(-1)
+    expect(inventory).toBeLessThan(firstTreeCall)
+    expect(inventory).toBeLessThan(importSource.indexOf('tx.quotePart.createMany'))
+  })
+})

+ 9 - 0
src/__tests__/features/inventory/low-stock-alerts.test.ts

@@ -25,6 +25,7 @@ function part(over: Partial<LowStockCandidate> = {}): LowStockCandidate {
     partNumber: 'BP-1',
     quantity: 1,
     minQuantity: 5,
+    unit: null,
     lowStockAlertedAt: null,
     ...over,
   }
@@ -169,6 +170,14 @@ describe('digest formatting', () => {
     )
   })
 
+  it('carries the unit of measure into the line', () => {
+    expect(
+      formatLowStockLine(
+        part({ name: 'Engine oil 5W-30', partNumber: null, quantity: 1.5, unit: 'l' })
+      )
+    ).toBe('Engine oil 5W-30: 1.5 l left, reorder at 5 l')
+  })
+
   it('omits an absent part number', () => {
     expect(formatLowStockLine(part({ partNumber: null, quantity: 0 }))).toBe(
       'Brake pad: 0 left, reorder at 5'

+ 17 - 4
src/__tests__/features/inventory/reconcile-stock.test.ts

@@ -154,7 +154,7 @@ describe('reconcileInventoryForParts', () => {
     expect(updates).toHaveLength(2)
   })
 
-  it('rounds fractional quantities to whole stock units', async () => {
+  it('deducts fractional quantities exactly (0.5 l stays 0.5, not 1)', async () => {
     const { tx, updates } = makeTx()
     await reconcileInventoryForParts(
       tx,
@@ -162,12 +162,25 @@ describe('reconcileInventoryForParts', () => {
       [],
       [
         { inventoryPartId: 'p1', quantity: 2.4 },
-        { inventoryPartId: 'p2', quantity: 2.6 },
+        { inventoryPartId: 'p2', quantity: 0.5 },
       ],
       CTX
     )
-    expect(updates).toContainEqual({ id: 'p1', organizationId: ORG, decrement: 2 })
-    expect(updates).toContainEqual({ id: 'p2', organizationId: ORG, decrement: 3 })
+    expect(updates).toContainEqual({ id: 'p1', organizationId: ORG, decrement: 2.4 })
+    expect(updates).toContainEqual({ id: 'p2', organizationId: ORG, decrement: 0.5 })
+  })
+
+  it('clamps quantities to 3 decimals so float noise cannot drift the balance', async () => {
+    const { tx, updates } = makeTx()
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [],
+      // 0.1 + 0.2 style noise: 0.30000000000000004 must enter the ledger as 0.3
+      [{ inventoryPartId: 'p1', quantity: 0.1 + 0.2 }],
+      CTX
+    )
+    expect(updates).toContainEqual({ id: 'p1', organizationId: ORG, decrement: 0.3 })
   })
 
   it("always scopes writes to the caller's organization", async () => {

+ 42 - 0
src/__tests__/features/inventory/units.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import {
+  IMPERIAL_MEASUREMENT_UNITS,
+  METRIC_MEASUREMENT_UNITS,
+  unitSuggestions,
+} from '@/features/inventory/Lib/units'
+
+describe('unitSuggestions', () => {
+  it('leads with the localized count units', () => {
+    const list = unitSuggestions('Stk,Satz', 'metric')
+    expect(list.slice(0, 2)).toEqual(['Stk', 'Satz'])
+  })
+
+  it('orders metric before imperial for a metric workshop', () => {
+    const list = unitSuggestions('pcs', 'metric')
+    expect(list.indexOf('l')).toBeLessThan(list.indexOf('qt'))
+    expect(list.indexOf('kg')).toBeLessThan(list.indexOf('lb'))
+  })
+
+  it('orders imperial before metric for an imperial workshop', () => {
+    const list = unitSuggestions('pcs', 'imperial')
+    expect(list.indexOf('qt')).toBeLessThan(list.indexOf('l'))
+    expect(list.indexOf('lb')).toBeLessThan(list.indexOf('kg'))
+  })
+
+  it('treats an unknown or missing system as metric', () => {
+    expect(unitSuggestions('pcs')).toEqual(unitSuggestions('pcs', 'metric'))
+  })
+
+  it('always offers both systems in full', () => {
+    const list = unitSuggestions('pcs', 'imperial')
+    for (const u of [...METRIC_MEASUREMENT_UNITS, ...IMPERIAL_MEASUREMENT_UNITS]) {
+      expect(list).toContain(u)
+    }
+  })
+
+  it('trims and dedupes against the localized list', () => {
+    const list = unitSuggestions(' pcs , l ,, pcs ', 'metric')
+    expect(list.filter((u) => u === 'pcs')).toHaveLength(1)
+    expect(list.filter((u) => u === 'l')).toHaveLength(1)
+  })
+})

+ 1 - 0
src/__tests__/features/tire-hotel/add-tire-line.test.ts

@@ -20,6 +20,7 @@ const LINE: TireLineInput = {
   name: 'Nokian Hakkapeliitta 225/45R18',
   partNumber: 'NOK-2254518',
   quantity: 4,
+  unit: null,
   unitPrice: 1800,
   unitCost: 1200,
   inventoryPartId: 'part-1',

+ 2 - 0
src/__tests__/features/workboard/datetime.test.ts

@@ -17,6 +17,8 @@ function makeJob(startDateTime: string | null, endDateTime: string | null): Work
     id: 'a1',
     type: 'serviceRecord',
     technicianId: 't1',
+    workBayId: null,
+    customerName: null,
     sortOrder: 0,
     title: 'Test',
     status: 'pending',

+ 416 - 0
src/__tests__/features/workboard/week-layout.test.ts

@@ -0,0 +1,416 @@
+import { describe, expect, it } from 'vitest'
+import type { WorkBoardJob } from '@/features/workboard/Actions/boardActions'
+import {
+  bookedMinutesOnDay,
+  computeTimeWindow,
+  hourMarks,
+  isUnscheduled,
+  layoutLaneDay,
+  resolveJobOnDay,
+  snapToStep,
+} from '@/features/workboard/utils/layout'
+import {
+  ALL_LANE,
+  UNLANED,
+  buildLanes,
+  groupJobsByLane,
+  isLaneGrouping,
+  laneAssignment,
+  laneIdForJob,
+} from '@/features/workboard/utils/lanes'
+
+/**
+ * Times are written without a zone so they parse as local, the same way the
+ * board reads them from the API. A test that pinned them to UTC would pass or
+ * fail depending on where it ran.
+ */
+function job(
+  id: string,
+  start: string | null,
+  end: string | null,
+  extra: Partial<WorkBoardJob> = {}
+): WorkBoardJob {
+  return {
+    id,
+    type: 'serviceRecord',
+    technicianId: 't1',
+    workBayId: null,
+    customerName: null,
+    sortOrder: 0,
+    title: id,
+    status: 'pending',
+    startDateTime: start,
+    endDateTime: end,
+    vehicle: null,
+    ...extra,
+  }
+}
+
+const DAY = '2026-08-25'
+const NEXT_DAY = '2026-08-26'
+const WINDOW = { startMins: 8 * 60, endMins: 18 * 60 }
+
+describe('resolveJobOnDay', () => {
+  it('places a job at its own hours', () => {
+    const result = resolveJobOnDay(job('a', `${DAY}T09:00:00`, `${DAY}T10:30:00`), DAY, WINDOW)
+    expect(result).toMatchObject({
+      startMins: 540,
+      endMins: 630,
+      continuesBefore: false,
+      continuesAfter: false,
+    })
+  })
+
+  it('ignores a job with no times', () => {
+    expect(resolveJobOnDay(job('a', null, null), DAY, WINDOW)).toBeNull()
+  })
+
+  it('ignores a job on another day', () => {
+    const other = job('a', `${NEXT_DAY}T09:00:00`, `${NEXT_DAY}T10:00:00`)
+    expect(resolveJobOnDay(other, DAY, WINDOW)).toBeNull()
+  })
+
+  it('marks the first day of a job that runs into the next', () => {
+    const long = job('a', `${DAY}T16:00:00`, `${NEXT_DAY}T09:00:00`)
+    expect(resolveJobOnDay(long, DAY, WINDOW)).toMatchObject({
+      startMins: 960,
+      endMins: WINDOW.endMins,
+      continuesBefore: false,
+      continuesAfter: true,
+    })
+  })
+
+  it('marks the second day of the same job', () => {
+    const long = job('a', `${DAY}T16:00:00`, `${NEXT_DAY}T09:00:00`)
+    expect(resolveJobOnDay(long, NEXT_DAY, WINDOW)).toMatchObject({
+      startMins: WINDOW.startMins,
+      endMins: 540,
+      continuesBefore: true,
+      continuesAfter: false,
+    })
+  })
+
+  it('clamps a job that starts before the visible hours', () => {
+    const early = job('a', `${DAY}T06:00:00`, `${DAY}T09:00:00`)
+    expect(resolveJobOnDay(early, DAY, WINDOW)).toMatchObject({
+      startMins: WINDOW.startMins,
+      endMins: 540,
+      continuesBefore: true,
+    })
+  })
+
+  it('drops a job that falls entirely outside the visible hours', () => {
+    const night = job('a', `${DAY}T04:00:00`, `${DAY}T05:00:00`)
+    expect(resolveJobOnDay(night, DAY, WINDOW)).toBeNull()
+  })
+
+  it('does not carry a job that ends exactly at midnight into the next day', () => {
+    const untilMidnight = job('a', `${DAY}T22:00:00`, `${NEXT_DAY}T00:00:00`)
+    expect(resolveJobOnDay(untilMidnight, NEXT_DAY, { startMins: 0, endMins: 1440 })).toBeNull()
+  })
+})
+
+describe('layoutLaneDay', () => {
+  it('gives a lone job the full width', () => {
+    const laid = layoutLaneDay([job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`)], DAY, WINDOW)
+    expect(laid).toHaveLength(1)
+    expect(laid[0]).toMatchObject({ column: 0, columns: 1 })
+  })
+
+  it('keeps consecutive jobs full width', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`),
+        job('b', `${DAY}T10:00:00`, `${DAY}T11:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([1, 1])
+    expect(laid.map((item) => item.column)).toEqual([0, 0])
+  })
+
+  it('splits two overlapping jobs side by side', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T11:00:00`),
+        job('b', `${DAY}T10:00:00`, `${DAY}T12:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([2, 2])
+    expect(laid.map((item) => item.column)).toEqual([0, 1])
+  })
+
+  it('widens the whole cluster when three jobs overlap', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T12:00:00`),
+        job('b', `${DAY}T09:30:00`, `${DAY}T11:00:00`),
+        job('c', `${DAY}T10:00:00`, `${DAY}T10:30:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([3, 3, 3])
+    expect(new Set(laid.map((item) => item.column)).size).toBe(3)
+  })
+
+  it('starts a fresh cluster once the lane is clear again', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T11:00:00`),
+        job('b', `${DAY}T10:00:00`, `${DAY}T12:00:00`),
+        job('c', `${DAY}T13:00:00`, `${DAY}T14:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([2, 2, 1])
+  })
+
+  it('reuses a freed column inside a cluster instead of narrowing everything', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`),
+        job('b', `${DAY}T09:00:00`, `${DAY}T11:00:00`),
+        job('c', `${DAY}T10:30:00`, `${DAY}T12:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    // Only two jobs ever run at once, so the cluster stays two columns wide
+    // and c takes the column a has finished with.
+    const columnById = Object.fromEntries(laid.map((item) => [item.job.id, item.column]))
+    expect(laid.map((item) => item.columns)).toEqual([2, 2, 2])
+    expect(columnById).toEqual({ a: 1, b: 0, c: 1 })
+  })
+
+  it('leaves out jobs that belong to another day', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`),
+        job('b', `${NEXT_DAY}T09:00:00`, `${NEXT_DAY}T10:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.job.id)).toEqual(['a'])
+  })
+})
+
+describe('computeTimeWindow', () => {
+  const days = [DAY, NEXT_DAY]
+
+  it('falls back to the configured work day', () => {
+    expect(computeTimeWindow([], days, 420, 900)).toEqual({ startMins: 420, endMins: 900 })
+  })
+
+  it('stretches to show a job that runs late', () => {
+    const late = job('a', `${DAY}T14:00:00`, `${DAY}T18:30:00`)
+    expect(computeTimeWindow([late], days, 420, 900)).toEqual({ startMins: 420, endMins: 1140 })
+  })
+
+  it('stretches to show a job that starts early', () => {
+    const early = job('a', `${DAY}T05:30:00`, `${DAY}T08:00:00`)
+    expect(computeTimeWindow([early], days, 420, 900)).toEqual({ startMins: 300, endMins: 900 })
+  })
+
+  it('does not flatten the week for a job that spans several days', () => {
+    const long = job('a', `${DAY}T13:00:00`, `${NEXT_DAY}T14:00:00`)
+    expect(computeTimeWindow([long], days, 420, 900)).toEqual({ startMins: 420, endMins: 900 })
+  })
+
+  it('opens the axis for the tail of a job carried in from before the week', () => {
+    const carried = job('a', '2026-08-24T20:00:00', `${DAY}T06:30:00`)
+    expect(computeTimeWindow([carried], days, 420, 900)).toMatchObject({ startMins: 360 })
+  })
+
+  it('ignores jobs that never touch the shown days', () => {
+    const elsewhere = job('a', '2026-09-10T05:00:00', '2026-09-10T06:00:00')
+    expect(computeTimeWindow([elsewhere], days, 420, 900)).toEqual({
+      startMins: 420,
+      endMins: 900,
+    })
+  })
+})
+
+describe('bookedMinutesOnDay', () => {
+  it('adds up the jobs on a day', () => {
+    const total = bookedMinutesOnDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:30:00`),
+        job('b', `${DAY}T13:00:00`, `${DAY}T14:00:00`),
+      ],
+      DAY
+    )
+    expect(total).toBe(150)
+  })
+
+  it('counts only the part of a multi-day job that falls on the day', () => {
+    const total = bookedMinutesOnDay([job('a', `${DAY}T22:00:00`, `${NEXT_DAY}T02:00:00`)], DAY)
+    expect(total).toBe(120)
+  })
+
+  it('counts work booked outside the visible hours', () => {
+    const total = bookedMinutesOnDay([job('a', `${DAY}T19:00:00`, `${DAY}T21:00:00`)], DAY)
+    expect(total).toBe(120)
+  })
+
+  it('ignores jobs with no times', () => {
+    expect(bookedMinutesOnDay([job('a', null, null)], DAY)).toBe(0)
+  })
+})
+
+describe('hourMarks', () => {
+  it('includes both ends of the window', () => {
+    expect(hourMarks({ startMins: 480, endMins: 660 })).toEqual([480, 540, 600, 660])
+  })
+
+  it('skips a partial hour before the window opens', () => {
+    expect(hourMarks({ startMins: 510, endMins: 660 })).toEqual([540, 600, 660])
+  })
+})
+
+describe('snapToStep', () => {
+  it('snaps to the nearest step', () => {
+    expect(snapToStep(547, 15, WINDOW)).toBe(540)
+    expect(snapToStep(553, 15, WINDOW)).toBe(555)
+  })
+
+  it('keeps the result inside the window', () => {
+    expect(snapToStep(60, 15, WINDOW)).toBe(WINDOW.startMins)
+    expect(snapToStep(2000, 15, WINDOW)).toBe(WINDOW.endMins)
+  })
+})
+
+describe('isUnscheduled', () => {
+  it('is true when either end is missing', () => {
+    expect(isUnscheduled(job('a', null, null))).toBe(true)
+    expect(isUnscheduled(job('a', `${DAY}T09:00:00`, null))).toBe(true)
+    expect(isUnscheduled(job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`))).toBe(false)
+  })
+})
+
+describe('lanes', () => {
+  const technicians = [
+    {
+      id: 't1',
+      name: 'Ada',
+      color: '#111111',
+      isActive: true,
+      sortOrder: 0,
+      dailyCapacity: 480,
+      userId: null,
+      organizationId: 'org',
+    },
+  ]
+  const bays = [
+    {
+      id: 'b1',
+      name: 'Lift 1',
+      color: '#222222',
+      isActive: true,
+      sortOrder: 0,
+      dailyCapacity: 600,
+      organizationId: 'org',
+    },
+  ]
+  const labels = { unlaned: 'None', all: 'All work' }
+
+  it('reads the lane a job belongs to for each grouping', () => {
+    const withBay = job('a', null, null, { workBayId: 'b1' })
+    expect(laneIdForJob(withBay, 'technician')).toBe('t1')
+    expect(laneIdForJob(withBay, 'bay')).toBe('b1')
+    expect(laneIdForJob(withBay, 'none')).toBe(ALL_LANE)
+  })
+
+  it('puts a job with no value for the grouping in the catch-all lane', () => {
+    expect(laneIdForJob(job('a', null, null), 'bay')).toBe(UNLANED)
+  })
+
+  it('hides the catch-all lane when every job has a value', () => {
+    const jobs = [job('a', null, null, { workBayId: 'b1' })]
+    const lanes = buildLanes({ grouping: 'bay', technicians, workBays: bays, jobs, labels })
+    expect(lanes.map((lane) => lane.id)).toEqual(['b1'])
+  })
+
+  it('shows the catch-all lane when something would otherwise vanish', () => {
+    const jobs = [job('a', null, null)]
+    const lanes = buildLanes({ grouping: 'bay', technicians, workBays: bays, jobs, labels })
+    expect(lanes.map((lane) => lane.id)).toEqual(['b1', UNLANED])
+    expect(lanes[1].isPlaceholder).toBe(true)
+  })
+
+  it('sums capacity into the single lane when nothing is grouped', () => {
+    const lanes = buildLanes({
+      grouping: 'none',
+      technicians,
+      workBays: bays,
+      jobs: [],
+      labels,
+    })
+    expect(lanes).toHaveLength(1)
+    expect(lanes[0]).toMatchObject({ id: ALL_LANE, dailyCapacity: 480 })
+  })
+
+  it('writes the field the grouping owns, and nothing else', () => {
+    expect(laneAssignment('t2', 'technician')).toEqual({ technicianId: 't2' })
+    expect(laneAssignment('b2', 'bay')).toEqual({ workBayId: 'b2' })
+    expect(laneAssignment(UNLANED, 'bay')).toEqual({ workBayId: null })
+    expect(laneAssignment(ALL_LANE, 'none')).toEqual({})
+  })
+
+  it('buckets jobs by lane', () => {
+    const jobs = [
+      job('a', null, null, { workBayId: 'b1' }),
+      job('b', null, null, { workBayId: 'b1' }),
+      job('c', null, null),
+    ]
+    const grouped = groupJobsByLane(jobs, 'bay')
+    expect(grouped.get('b1')?.map((item) => item.id)).toEqual(['a', 'b'])
+    expect(grouped.get(UNLANED)?.map((item) => item.id)).toEqual(['c'])
+  })
+
+  it('validates a grouping coming in from the URL', () => {
+    expect(isLaneGrouping('bay')).toBe(true)
+    expect(isLaneGrouping('bays')).toBe(false)
+    expect(isLaneGrouping(null)).toBe(false)
+  })
+})
+
+describe('lanes that are no longer on the board', () => {
+  const bays = [
+    {
+      id: 'b1',
+      name: 'Lift 1',
+      color: '#222222',
+      isActive: true,
+      sortOrder: 0,
+      dailyCapacity: 600,
+      organizationId: 'org',
+    },
+  ]
+
+  it('folds work pointing at an unknown lane into the catch-all', () => {
+    const orphan = job('a', null, null, { workBayId: 'deleted-bay' })
+    expect(laneIdForJob(orphan, 'bay')).toBe('deleted-bay')
+    expect(laneIdForJob(orphan, 'bay', new Set(['b1']))).toBe(UNLANED)
+  })
+
+  it('shows the catch-all lane for it, so the job stays visible', () => {
+    const jobs = [job('a', null, null, { workBayId: 'deleted-bay' })]
+    const lanes = buildLanes({
+      grouping: 'bay',
+      technicians: [],
+      workBays: bays,
+      jobs,
+      labels: { unlaned: 'None', all: 'All work' },
+    })
+    expect(lanes.map((lane) => lane.id)).toEqual(['b1', UNLANED])
+
+    const grouped = groupJobsByLane(jobs, 'bay', new Set(['b1']))
+    expect(grouped.get(UNLANED)?.map((item) => item.id)).toEqual(['a'])
+  })
+})

+ 50 - 0
src/__tests__/features/workboard/week-number.test.ts

@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { isoWeekNumber } from '@/features/workboard/Components/WorkBoardToolbar'
+import { formatClock } from '@/features/workboard/utils/clock'
+
+/**
+ * The workshop that asked for this view schedules by ISO week number, and the
+ * planner it uses shows "KW 35" for the week of Monday 24 August 2026. An
+ * off-by-one here would be wrong in the one place the number is read aloud.
+ */
+describe('isoWeekNumber', () => {
+  it('matches the reference planner', () => {
+    expect(isoWeekNumber('2026-08-24')).toBe(35)
+  })
+
+  it('gives the same number whichever weekday the shop starts on', () => {
+    expect(isoWeekNumber('2026-08-23')).toBe(35)
+  })
+
+  it('counts the week that straddles New Year as week one', () => {
+    expect(isoWeekNumber('2025-12-29')).toBe(1)
+    expect(isoWeekNumber('2026-01-01')).toBe(1)
+  })
+
+  it('knows a 53-week year', () => {
+    expect(isoWeekNumber('2026-12-28')).toBe(53)
+    expect(isoWeekNumber('2027-01-04')).toBe(1)
+  })
+})
+
+describe('formatClock', () => {
+  it('pads a 24-hour clock', () => {
+    expect(formatClock(7 * 60, '24h')).toBe('07:00')
+    expect(formatClock(13 * 60 + 5, '24h')).toBe('13:05')
+  })
+
+  it('drops the minutes on the hour in 12-hour time, where the gutter is tight', () => {
+    expect(formatClock(7 * 60, '12h')).toBe('7 AM')
+    expect(formatClock(13 * 60, '12h')).toBe('1 PM')
+  })
+
+  it('keeps them off the hour', () => {
+    expect(formatClock(13 * 60 + 30, '12h')).toBe('1:30 PM')
+  })
+
+  it('calls the ends of the day midnight and noon', () => {
+    expect(formatClock(0, '12h')).toBe('12 AM')
+    expect(formatClock(12 * 60, '12h')).toBe('12 PM')
+    expect(formatClock(1440, '12h')).toBe('12 AM')
+  })
+})

+ 32 - 0
src/__tests__/features/workorders/add-part.test.ts

@@ -163,6 +163,38 @@ describe('addPartToServiceRecord — totals recalculation', () => {
     expect(updateCall.data.totalAmount).toBeCloseTo(88)
   })
 
+  it('snapshots the unit of measure onto the created line', async () => {
+    setupAuth()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      id: RECORD_ID,
+      vehicleId: VEHICLE_ID,
+      subtotal: 0,
+      taxRate: 0,
+      taxInclusive: false,
+      discountType: null,
+      discountValue: 0,
+    } as any)
+    vi.mocked(db.servicePart.create).mockResolvedValue({ id: 'part-1' } as any)
+    vi.mocked(db.servicePart.aggregate).mockResolvedValue({ _sum: { total: 23 } } as any)
+    vi.mocked(db.serviceLabor.aggregate).mockResolvedValue({ _sum: { total: 0 } } as any)
+
+    await addPartToServiceRecord({
+      serviceRecordId: RECORD_ID,
+      name: 'Engine oil 5W-30',
+      quantity: 2.5,
+      unit: 'l',
+      unitPrice: 9.2,
+      total: 23,
+      unitCost: 6,
+    })
+
+    expect(vi.mocked(db.servicePart.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ quantity: 2.5, unit: 'l' }),
+      })
+    )
+  })
+
   it('rejects when the service record does not belong to the org', async () => {
     setupAuth()
     vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)

+ 45 - 48
src/app/(authenticated)/inventory/[id]/page.tsx

@@ -1,62 +1,62 @@
-import { notFound } from "next/navigation";
+import { notFound } from 'next/navigation'
 import {
   getInventoryPart,
   getStockMovementsPaginated,
-} from "@/features/inventory/Actions/getStockMovements";
-import { getInventoryCategories } from "@/features/inventory/Actions/inventoryActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PageHeader } from "@/components/page-header";
-import { formatDateTime } from "@/lib/format";
-import { PartDetailClient } from "./part-detail-client";
+} from '@/features/inventory/Actions/getStockMovements'
+import { getInventoryCategories } from '@/features/inventory/Actions/inventoryActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PageHeader } from '@/components/page-header'
+import { formatDateTime } from '@/lib/format'
+import { PartDetailClient } from './part-detail-client'
 
-const PAGE_SIZE = 50;
+const PAGE_SIZE = 50
 
 export default async function InventoryPartDetailPage({
   params,
   searchParams,
 }: {
-  params: Promise<{ id: string }>;
-  searchParams: Promise<{ page?: string; reason?: string }>;
+  params: Promise<{ id: string }>
+  searchParams: Promise<{ page?: string; reason?: string }>
 }) {
-  const { id } = await params;
-  const sp = await searchParams;
-  const page = Math.max(1, Number(sp.page) || 1);
+  const { id } = await params
+  const sp = await searchParams
+  const page = Math.max(1, Number(sp.page) || 1)
 
-  const [partResult, movementsResult, categoriesResult, settingsResult] =
-    await Promise.all([
-      getInventoryPart(id),
-      getStockMovementsPaginated({
-        inventoryPartId: id,
-        page,
-        pageSize: PAGE_SIZE,
-        reason: sp.reason,
-      }),
-      getInventoryCategories(),
-      getSettings([
-        SETTING_KEYS.CURRENCY_CODE,
-        SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
-        SETTING_KEYS.DATE_FORMAT,
-        SETTING_KEYS.TIME_FORMAT,
-        SETTING_KEYS.TIMEZONE,
-        SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
-      ]),
-    ]);
+  const [partResult, movementsResult, categoriesResult, settingsResult] = await Promise.all([
+    getInventoryPart(id),
+    getStockMovementsPaginated({
+      inventoryPartId: id,
+      page,
+      pageSize: PAGE_SIZE,
+      reason: sp.reason,
+    }),
+    getInventoryCategories(),
+    getSettings([
+      SETTING_KEYS.CURRENCY_CODE,
+      SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+      SETTING_KEYS.UNIT_SYSTEM,
+      SETTING_KEYS.DATE_FORMAT,
+      SETTING_KEYS.TIME_FORMAT,
+      SETTING_KEYS.TIMEZONE,
+      SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
+    ]),
+  ])
 
   // Covers both "no such part" and "belongs to another organization", since
   // the action scopes by organizationId.
   if (!partResult.success || !partResult.data) {
-    notFound();
+    notFound()
   }
 
-  const settings = settingsResult.success ? (settingsResult.data ?? {}) : {};
+  const settings = settingsResult.success ? (settingsResult.data ?? {}) : {}
   const movements = movementsResult.data ?? {
     movements: [],
     total: 0,
     page,
     pageSize: PAGE_SIZE,
     totalPages: 1,
-  };
+  }
 
   // Timestamps are formatted here, on the server, and shipped as strings.
   // Formatting them in the client component instead would render the server's
@@ -69,10 +69,10 @@ export default async function InventoryPartDetailPage({
     createdAtLabel: formatDateTime(
       m.createdAt,
       settings[SETTING_KEYS.DATE_FORMAT],
-      (settings[SETTING_KEYS.TIME_FORMAT] as "12h" | "24h") || undefined,
-      settings[SETTING_KEYS.TIMEZONE] || undefined,
+      (settings[SETTING_KEYS.TIME_FORMAT] as '12h' | '24h') || undefined,
+      settings[SETTING_KEYS.TIMEZONE] || undefined
     ),
-  }));
+  }))
 
   return (
     <>
@@ -84,16 +84,13 @@ export default async function InventoryPartDetailPage({
         page={movements.page}
         pageSize={movements.pageSize}
         totalPages={movements.totalPages}
-        reason={sp.reason ?? ""}
-        currencyCode={settings[SETTING_KEYS.CURRENCY_CODE] || "USD"}
-        markupMultiplier={
-          Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1
-        }
+        reason={sp.reason ?? ''}
+        currencyCode={settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'}
+        markupMultiplier={Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1}
         categories={categoriesResult.data ?? []}
-        lowStockDefault={
-          Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0
-        }
+        lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
+        unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
       />
     </>
-  );
+  )
 }

+ 130 - 113
src/app/(authenticated)/inventory/[id]/part-detail-client.tsx

@@ -1,11 +1,11 @@
-"use client";
+'use client'
 
-import { useCallback, useState } from "react";
-import Link from "next/link";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
+import { useCallback, useState } from 'react'
+import Link from 'next/link'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
 import {
   Table,
   TableBody,
@@ -13,39 +13,40 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { InventoryPartForm } from "@/features/inventory/Components/InventoryPartForm";
-import { STOCK_MOVEMENT_REASONS } from "@/features/inventory/Lib/stockMovementReasons";
-import { isLow as isLowStock } from "@/features/inventory/Lib/lowStockAlerts";
-import { useFormatCurrency } from "@/components/currency-settings-context";
-import { ArrowLeft, ArrowUpRight, Pencil } from "lucide-react";
+} from '@/components/ui/select'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
+import { STOCK_MOVEMENT_REASONS } from '@/features/inventory/Lib/stockMovementReasons'
+import { isLow as isLowStock } from '@/features/inventory/Lib/lowStockAlerts'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { formatQuantity } from '@/lib/format-quantity'
+import { ArrowLeft, ArrowUpRight, Pencil } from 'lucide-react'
 
 interface Movement {
-  id: string;
-  delta: number;
-  quantityAfter: number;
-  reason: string;
-  note: string | null;
-  createdAt: string;
+  id: string
+  delta: number
+  quantityAfter: number
+  reason: string
+  note: string | null
+  createdAt: string
   /** Pre-formatted on the server so SSR and hydration agree. */
-  createdAtLabel: string;
-  userName: string | null;
-  serviceRecordId: string | null;
-  vehicleId: string | null;
-  label: string | null;
-  vehicle: string | null;
+  createdAtLabel: string
+  userName: string | null
+  serviceRecordId: string | null
+  vehicleId: string | null
+  label: string | null
+  vehicle: string | null
 }
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-type Part = any;
+type Part = any
 
 export function PartDetailClient({
   part,
@@ -59,62 +60,65 @@ export function PartDetailClient({
   markupMultiplier,
   categories,
   lowStockDefault,
+  unitSystem,
 }: {
-  part: Part;
-  movements: Movement[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
-  reason: string;
-  currencyCode: string;
-  markupMultiplier: number;
-  categories: string[];
-  lowStockDefault: number;
+  part: Part
+  movements: Movement[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+  reason: string
+  currencyCode: string
+  markupMultiplier: number
+  categories: string[]
+  lowStockDefault: number
+  /** workshop.unitSystem, orders unit suggestions in the edit form. */
+  unitSystem?: string
 }) {
-  const t = useTranslations("inventory");
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const formatCurrency = useFormatCurrency();
-  const [showForm, setShowForm] = useState(false);
+  const t = useTranslations('inventory')
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const formatCurrency = useFormatCurrency()
+  const [showForm, setShowForm] = useState(false)
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const next = new URLSearchParams(searchParams.toString());
+      const next = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") next.delete(key);
-        else next.set(key, String(value));
+        if (value === undefined || value === '') next.delete(key)
+        else next.set(key, String(value))
       }
       // Changing the filter invalidates the current page offset.
-      if (!("page" in params) && "reason" in params) next.delete("page");
-      const qs = next.toString();
-      router.push(qs ? `${pathname}?${qs}` : pathname);
+      if (!('page' in params) && 'reason' in params) next.delete('page')
+      const qs = next.toString()
+      router.push(qs ? `${pathname}?${qs}` : pathname)
     },
-    [router, pathname, searchParams],
-  );
+    [router, pathname, searchParams]
+  )
 
   // Same rule as the alert engine, so the badge never disagrees with alerts.
-  const isLow = isLowStock(part, lowStockDefault);
+  const isLow = isLowStock(part, lowStockDefault)
 
   const reasonLabel = (value: string) => {
     switch (value) {
-      case "service_record":
-        return t("history.reasons.service_record");
-      case "service_record_deleted":
-        return t("history.reasons.service_record_deleted");
-      case "quote_conversion":
-        return t("history.reasons.quote_conversion");
-      case "manual_adjustment":
-        return t("history.reasons.manual_adjustment");
-      case "bulk_markup":
-        return t("history.reasons.bulk_markup");
+      case 'service_record':
+        return t('history.reasons.service_record')
+      case 'service_record_deleted':
+        return t('history.reasons.service_record_deleted')
+      case 'quote_conversion':
+        return t('history.reasons.quote_conversion')
+      case 'manual_adjustment':
+        return t('history.reasons.manual_adjustment')
+      case 'bulk_markup':
+        return t('history.reasons.bulk_markup')
       default:
         // A row written by a newer build may carry a reason this one has no
         // translation for; show the raw value rather than an empty cell.
-        return value;
+        return value
     }
-  };
+  }
 
   return (
     <div className="space-y-6 p-4 pt-0">
@@ -123,50 +127,48 @@ export function PartDetailClient({
           <Button variant="ghost" size="sm" asChild className="-ml-2 h-7">
             <Link href="/inventory">
               <ArrowLeft className="mr-1 h-3.5 w-3.5" />
-              {t("history.backToInventory")}
+              {t('history.backToInventory')}
             </Link>
           </Button>
           <h1 className="text-2xl font-bold tracking-tight">{part.name}</h1>
           <p className="text-sm text-muted-foreground">
-            {[part.partNumber, part.category, part.location]
-              .filter(Boolean)
-              .join(" · ") || "—"}
+            {[part.partNumber, part.category, part.location].filter(Boolean).join(' · ') || '—'}
           </p>
         </div>
         <Button
           onClick={() => setShowForm(true)}
-          aria-label={t("actions.edit")}
-          title={t("actions.edit")}
+          aria-label={t('actions.edit')}
+          title={t('actions.edit')}
           className="h-9 w-9 shrink-0 p-0 md:w-auto md:px-4"
         >
           <Pencil className="h-4 w-4 md:mr-2" />
-          <span className="hidden md:inline">{t("actions.edit")}</span>
+          <span className="hidden md:inline">{t('actions.edit')}</span>
         </Button>
       </div>
 
       {/* Summary — the context you need while reading the ledger below. */}
       <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("table.inStock")}</p>
-          <p className={`text-lg font-bold ${isLow ? "text-destructive" : ""}`}>
-            {part.quantity}
+          <p className="text-[11px] text-muted-foreground">{t('table.inStock')}</p>
+          <p className={`text-lg font-bold ${isLow ? 'text-destructive' : ''}`}>
+            {formatQuantity(part.quantity, part.unit)}
             {isLow && (
               <Badge variant="destructive" className="ml-2 px-1.5 py-0 text-[10px]">
-                {t("table.low")}
+                {t('table.low')}
               </Badge>
             )}
           </p>
         </div>
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("form.minQty")}</p>
-          <p className="text-lg font-bold">{part.minQuantity}</p>
+          <p className="text-[11px] text-muted-foreground">{t('form.minQty')}</p>
+          <p className="text-lg font-bold">{formatQuantity(part.minQuantity, part.unit)}</p>
         </div>
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("table.unitCost")}</p>
+          <p className="text-[11px] text-muted-foreground">{t('table.unitCost')}</p>
           <p className="text-lg font-bold">{formatCurrency(part.unitCost, currencyCode)}</p>
         </div>
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("table.sellPrice")}</p>
+          <p className="text-[11px] text-muted-foreground">{t('table.sellPrice')}</p>
           <p className="text-lg font-bold">{formatCurrency(part.sellPrice, currencyCode)}</p>
         </div>
       </div>
@@ -174,20 +176,20 @@ export function PartDetailClient({
       <div className="space-y-3">
         <div className="flex flex-wrap items-center justify-between gap-2">
           <div>
-            <h2 className="text-lg font-semibold">{t("history.title")}</h2>
+            <h2 className="text-lg font-semibold">{t('history.title')}</h2>
             <p className="text-sm text-muted-foreground">
-              {t("history.description", { name: part.name })}
+              {t('history.description', { name: part.name })}
             </p>
           </div>
           <Select
-            value={reason || "all"}
-            onValueChange={(v) => navigate({ reason: v === "all" ? undefined : v })}
+            value={reason || 'all'}
+            onValueChange={(v) => navigate({ reason: v === 'all' ? undefined : v })}
           >
             <SelectTrigger className="h-9 w-full sm:w-[220px]">
               <SelectValue />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("history.allReasons")}</SelectItem>
+              <SelectItem value="all">{t('history.allReasons')}</SelectItem>
               {STOCK_MOVEMENT_REASONS.map((r) => (
                 <SelectItem key={r} value={r}>
                   {reasonLabel(r)}
@@ -201,7 +203,7 @@ export function PartDetailClient({
         <div className="space-y-2 md:hidden">
           {movements.length === 0 ? (
             <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-              {t("history.empty")}
+              {t('history.empty')}
             </div>
           ) : (
             movements.map((m) => (
@@ -210,14 +212,18 @@ export function PartDetailClient({
                   <div className="min-w-0 flex-1">
                     {m.serviceRecordId ? (
                       <Link
-                        href={m.vehicleId ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}` : `/sales/${m.serviceRecordId}`}
+                        href={
+                          m.vehicleId
+                            ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}`
+                            : `/sales/${m.serviceRecordId}`
+                        }
                         className="inline-flex items-center gap-1 font-medium text-primary hover:underline"
                       >
                         {m.label}
                         <ArrowUpRight className="h-3 w-3" />
                       </Link>
                     ) : (
-                      <p className="font-medium">{m.label ?? "—"}</p>
+                      <p className="font-medium">{m.label ?? '—'}</p>
                     )}
                     {m.vehicle && (
                       <p className="truncate text-xs text-muted-foreground">{m.vehicle}</p>
@@ -229,13 +235,17 @@ export function PartDetailClient({
                       variant="outline"
                       className={
                         m.delta < 0
-                          ? "border-destructive/40 text-destructive"
-                          : "border-green-500/40 text-green-600"
+                          ? 'border-destructive/40 text-destructive'
+                          : 'border-green-500/40 text-green-600'
                       }
                     >
-                      {m.delta > 0 ? `+${m.delta}` : m.delta}
+                      {m.delta > 0
+                        ? `+${formatQuantity(m.delta, part.unit)}`
+                        : formatQuantity(m.delta, part.unit)}
                     </Badge>
-                    <span className="font-medium">{m.quantityAfter}</span>
+                    <span className="font-medium">
+                      {formatQuantity(m.quantityAfter, part.unit)}
+                    </span>
                   </div>
                 </div>
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
@@ -253,19 +263,19 @@ export function PartDetailClient({
           <Table>
             <TableHeader>
               <TableRow>
-                <TableHead>{t("history.when")}</TableHead>
-                <TableHead>{t("history.change")}</TableHead>
-                <TableHead>{t("history.balance")}</TableHead>
-                <TableHead>{t("history.usedOn")}</TableHead>
-                <TableHead>{t("history.reason")}</TableHead>
-                <TableHead>{t("history.by")}</TableHead>
+                <TableHead>{t('history.when')}</TableHead>
+                <TableHead>{t('history.change')}</TableHead>
+                <TableHead>{t('history.balance')}</TableHead>
+                <TableHead>{t('history.usedOn')}</TableHead>
+                <TableHead>{t('history.reason')}</TableHead>
+                <TableHead>{t('history.by')}</TableHead>
               </TableRow>
             </TableHeader>
             <TableBody>
               {movements.length === 0 ? (
                 <TableRow>
                   <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
-                    {t("history.empty")}
+                    {t('history.empty')}
                   </TableCell>
                 </TableRow>
               ) : (
@@ -279,25 +289,33 @@ export function PartDetailClient({
                         variant="outline"
                         className={
                           m.delta < 0
-                            ? "border-destructive/40 text-destructive"
-                            : "border-green-500/40 text-green-600"
+                            ? 'border-destructive/40 text-destructive'
+                            : 'border-green-500/40 text-green-600'
                         }
                       >
-                        {m.delta > 0 ? `+${m.delta}` : m.delta}
+                        {m.delta > 0
+                          ? `+${formatQuantity(m.delta, part.unit)}`
+                          : formatQuantity(m.delta, part.unit)}
                       </Badge>
                     </TableCell>
-                    <TableCell className="font-medium">{m.quantityAfter}</TableCell>
+                    <TableCell className="font-medium">
+                      {formatQuantity(m.quantityAfter, part.unit)}
+                    </TableCell>
                     <TableCell>
                       {m.serviceRecordId ? (
                         <Link
-                          href={m.vehicleId ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}` : `/sales/${m.serviceRecordId}`}
+                          href={
+                            m.vehicleId
+                              ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}`
+                              : `/sales/${m.serviceRecordId}`
+                          }
                           className="inline-flex items-center gap-1 text-primary hover:underline"
                         >
                           {m.label}
                           <ArrowUpRight className="h-3 w-3" />
                         </Link>
                       ) : (
-                        <span className="text-muted-foreground">{m.label ?? "—"}</span>
+                        <span className="text-muted-foreground">{m.label ?? '—'}</span>
                       )}
                       {m.vehicle && (
                         <span className="block text-xs text-muted-foreground">{m.vehicle}</span>
@@ -306,10 +324,8 @@ export function PartDetailClient({
                         <span className="block text-xs text-muted-foreground">{m.note}</span>
                       )}
                     </TableCell>
-                    <TableCell className="text-muted-foreground">
-                      {reasonLabel(m.reason)}
-                    </TableCell>
-                    <TableCell className="text-muted-foreground">{m.userName ?? "—"}</TableCell>
+                    <TableCell className="text-muted-foreground">{reasonLabel(m.reason)}</TableCell>
+                    <TableCell className="text-muted-foreground">{m.userName ?? '—'}</TableCell>
                   </TableRow>
                 ))
               )}
@@ -331,13 +347,14 @@ export function PartDetailClient({
         key={part.id}
         open={showForm}
         onOpenChange={(open) => {
-          setShowForm(open);
-          if (!open) router.refresh();
+          setShowForm(open)
+          if (!open) router.refresh()
         }}
         part={part}
         markupMultiplier={markupMultiplier}
+        unitSystem={unitSystem}
         categories={categories}
       />
     </div>
-  );
+  )
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 439 - 359
src/app/(authenticated)/inventory/inventory-client.tsx


+ 39 - 27
src/app/(authenticated)/inventory/page.tsx

@@ -1,15 +1,27 @@
-import { getInventoryPartsPaginated, getInventoryCategories, hasAnyReorderPoint } from "@/features/inventory/Actions/inventoryActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { InventoryClient } from "./inventory-client";
-import { PageHeader } from "@/components/page-header";
+import {
+  getInventoryPartsPaginated,
+  getInventoryCategories,
+  hasAnyReorderPoint,
+} from '@/features/inventory/Actions/inventoryActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { InventoryClient } from './inventory-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function InventoryPage({
   searchParams,
 }: {
-  searchParams: Promise<{ page?: string; pageSize?: string; search?: string; category?: string; sortBy?: string; sortOrder?: string; lowStock?: string }>;
+  searchParams: Promise<{
+    page?: string
+    pageSize?: string
+    search?: string
+    category?: string
+    sortBy?: string
+    sortOrder?: string
+    lowStock?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
   const [result, categoriesResult, settingsResult, reorderResult] = await Promise.all([
     getInventoryPartsPaginated({
       page: params.page ? parseInt(params.page) : 1,
@@ -17,35 +29,35 @@ export default async function InventoryPage({
       search: params.search,
       category: params.category,
       sortBy: params.sortBy,
-      sortOrder: (params.sortOrder as "asc" | "desc") || undefined,
-      lowStock: params.lowStock === "1",
+      sortOrder: (params.sortOrder as 'asc' | 'desc') || undefined,
+      lowStock: params.lowStock === '1',
     }),
     getInventoryCategories(),
     getSettings([
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+      SETTING_KEYS.INVENTORY_DEFAULT_UNIT,
+      SETTING_KEYS.UNIT_SYSTEM,
       SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
     ]),
     hasAnyReorderPoint(),
-  ]);
+  ])
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load inventory"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load inventory'}</p>
         </div>
       </>
-    );
+    )
   }
 
-  const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : [];
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0;
+  const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : []
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0
 
   return (
     <>
@@ -53,20 +65,20 @@ export default async function InventoryPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <InventoryClient
           data={result.data}
-          search={params.search || ""}
-          category={params.category || ""}
+          search={params.search || ''}
+          category={params.category || ''}
           categories={categories}
           currencyCode={currencyCode}
           markupMultiplier={markupMultiplier}
-          sortBy={params.sortBy || "updatedAt"}
-          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
-          lowStockDefault={
-            Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0
-          }
-          lowStockOnly={params.lowStock === "1"}
+          defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}
+          unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
+          sortBy={params.sortBy || 'updatedAt'}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
+          lowStockOnly={params.lowStock === '1'}
           hasAnyReorderPoint={reorderResult.data ?? false}
         />
       </div>
     </>
-  );
+  )
 }

+ 229 - 178
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -1,14 +1,14 @@
-"use client";
+'use client'
 
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
-import { interactiveRow } from '@/lib/interactive-row';
-import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { interactiveRow } from '@/lib/interactive-row'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
-import { useState, useCallback, useTransition } from "react";
-import { useTranslations } from "next-intl";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
+import { useState, useCallback, useTransition } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
 import {
   Table,
   TableBody,
@@ -16,41 +16,54 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
   DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+} from '@/components/ui/dropdown-menu'
 import {
   ContextMenu,
   ContextMenuContent,
   ContextMenuItem,
   ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
-import { useGlassModal } from "@/components/glass-modal";
-import { useConfirm } from "@/components/confirm-dialog";
-import { LaborPresetForm } from "@/features/labor-presets/Components/LaborPresetForm";
-import { deleteLaborPreset, getLaborPreset } from "@/features/labor-presets/Actions/laborPresetActions";
-import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, MoreVertical, Pencil, Plus, Search, Trash2 } from "lucide-react";
+} from '@/components/ui/context-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { TableContextMenuHint } from '@/components/table-context-menu-hint'
+import { useGlassModal } from '@/components/glass-modal'
+import { useConfirm } from '@/components/confirm-dialog'
+import { LaborPresetForm } from '@/features/labor-presets/Components/LaborPresetForm'
+import {
+  deleteLaborPreset,
+  getLaborPreset,
+} from '@/features/labor-presets/Actions/laborPresetActions'
+import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
+  Loader2,
+  MoreVertical,
+  Pencil,
+  Plus,
+  Search,
+  Trash2,
+} from 'lucide-react'
 
 interface LaborPresetRow {
-  id: string;
-  name: string;
-  description: string | null;
-  _count: { items: number };
-  items: { hours: number; pricingType: string }[];
+  id: string
+  name: string
+  description: string | null
+  _count: { items: number }
+  items: { hours: number; pricingType: string }[]
 }
 
 interface PaginatedData {
-  presets: LaborPresetRow[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
+  presets: LaborPresetRow[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
 }
 
 export function LaborPresetsClient({
@@ -58,51 +71,68 @@ export function LaborPresetsClient({
   search,
   sortBy,
   sortOrder,
-  currencyCode = "USD",
+  currencyCode = 'USD',
   defaultLaborRate = 0,
+  inventoryParts = [],
 }: {
-  data: PaginatedData;
-  search: string;
-  sortBy: string;
-  sortOrder: "asc" | "desc";
-  currencyCode?: string;
-  defaultLaborRate?: number;
+  data: PaginatedData
+  search: string
+  sortBy: string
+  sortOrder: 'asc' | 'desc'
+  currencyCode?: string
+  defaultLaborRate?: number
+  /** Stocked parts for the preset form's "import from inventory" picker. */
+  inventoryParts?: {
+    id: string
+    name: string
+    partNumber: string | null
+    unit: string | null
+    sellPrice: number
+    unitCost: number
+    quantity: number
+  }[]
 }) {
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const t = useTranslations("laborPresets");
-  const [isPending, startTransition] = useTransition();
-  const tableNav = useTableKeyboardNav();
-  const [showForm, setShowForm] = useState(false);
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const t = useTranslations('laborPresets')
+  const [isPending, startTransition] = useTransition()
+  const tableNav = useTableKeyboardNav()
+  const [showForm, setShowForm] = useState(false)
   const [editPreset, setEditPreset] = useState<{
-    id: string;
-    name: string;
-    description: string | null;
-    items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
-  } | null>(null);
-  const modal = useGlassModal();
-  const confirm = useConfirm();
+    id: string
+    name: string
+    description: string | null
+    items: {
+      description: string
+      hours: number
+      rate: number
+      pricingType?: string
+      sortOrder: number
+    }[]
+  } | null>(null)
+  const modal = useGlassModal()
+  const confirm = useConfirm()
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") {
-          newParams.delete(key);
+        if (value === undefined || value === '') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && "search" in params) {
-        newParams.delete("page");
+      if (!('page' in params) && 'search' in params) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
     [router, pathname, searchParams]
-  );
+  )
 
   // Live search: filters as you type, no Enter required. Submitting the
   // form (Enter) commits immediately, bypassing the debounce.
@@ -110,48 +140,50 @@ export function LaborPresetsClient({
     value: searchInput,
     setValue: setSearchInput,
     commitNow: handleSearch,
-  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }))
 
   const handleSort = useCallback(
     (column: string) => {
-      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
-      navigate({ sortBy: column, sortOrder: newOrder });
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
+      navigate({ sortBy: column, sortOrder: newOrder })
     },
     [navigate, sortBy, sortOrder]
-  );
+  )
 
   const SortIcon = ({ column }: { column: string }) => {
-    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
-    return sortOrder === "asc"
-      ? <ArrowUp className="ml-1 h-3 w-3" />
-      : <ArrowDown className="ml-1 h-3 w-3" />;
-  };
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
+  }
 
   const handleEdit = async (id: string) => {
-    const result = await getLaborPreset(id);
+    const result = await getLaborPreset(id)
     if (result.success && result.data) {
-      setEditPreset(result.data);
-      setShowForm(true);
+      setEditPreset(result.data)
+      setShowForm(true)
     } else {
-      modal.open("error", t("errors.error"), result.error || t("errors.loadFailed"));
+      modal.open('error', t('errors.error'), result.error || t('errors.loadFailed'))
     }
-  };
+  }
 
   const handleDelete = async (id: string, name: string) => {
     const ok = await confirm({
-      title: t("deletePreset.title"),
-      description: t("deletePreset.description", { name }),
-      confirmLabel: t("deletePreset.confirm"),
+      title: t('deletePreset.title'),
+      description: t('deletePreset.description', { name }),
+      confirmLabel: t('deletePreset.confirm'),
       destructive: true,
-    });
-    if (!ok) return;
-    const result = await deleteLaborPreset(id);
+    })
+    if (!ok) return
+    const result = await deleteLaborPreset(id)
     if (result.success) {
-      router.refresh();
+      router.refresh()
     } else {
-      modal.open("error", t("errors.error"), result.error || t("errors.deleteFailed"));
+      modal.open('error', t('errors.error'), result.error || t('errors.deleteFailed'))
     }
-  };
+  }
 
   return (
     <div className="space-y-4">
@@ -161,7 +193,7 @@ export function LaborPresetsClient({
           <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
             <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
             <Input
-              placeholder={t("searchPlaceholder")}
+              placeholder={t('searchPlaceholder')}
               value={searchInput}
               onChange={(e) => setSearchInput(e.target.value)}
               className="h-9 pl-9"
@@ -173,12 +205,12 @@ export function LaborPresetsClient({
         <Button
           size="sm"
           onClick={() => setShowForm(true)}
-          aria-label={t("addPackage")}
-          title={t("addPackage")}
+          aria-label={t('addPackage')}
+          title={t('addPackage')}
           className="h-9 w-9 shrink-0 p-0 md:h-8 md:w-auto md:px-3"
         >
           <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
-          <span className="hidden md:inline">{t("addPackage")}</span>
+          <span className="hidden md:inline">{t('addPackage')}</span>
         </Button>
       </div>
 
@@ -186,14 +218,14 @@ export function LaborPresetsClient({
       <div className="space-y-2 md:hidden">
         {data.presets.length === 0 ? (
           <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-            {search ? t("empty.noMatch") : t("empty.noPresets")}
+            {search ? t('empty.noMatch') : t('empty.noPresets')}
           </div>
         ) : (
           data.presets.map((preset) => {
-            const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
-            const serviceItems = preset.items.filter((i) => i.pricingType === "service");
-            const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
-            const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
+            const hourlyItems = preset.items.filter((i) => i.pricingType !== 'service')
+            const serviceItems = preset.items.filter((i) => i.pricingType === 'service')
+            const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
+            const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
             return (
               <div key={preset.id} className="flex items-start gap-2 rounded-lg border bg-card p-3">
                 <button
@@ -209,16 +241,16 @@ export function LaborPresetsClient({
                   )}
                   <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
                     <span>
-                      {t("table.itemCount")}: {preset._count.items}
+                      {t('table.itemCount')}: {preset._count.items}
                     </span>
                     {totalHours > 0 && (
                       <span className="inline-flex items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">
-                        {totalHours} {t("table.hrs")}
+                        {totalHours} {t('table.hrs')}
                       </span>
                     )}
                     {totalUnits > 0 && (
                       <span className="inline-flex items-center rounded-md border border-blue-500/30 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
-                        {totalUnits} {t("table.units")}
+                        {totalUnits} {t('table.units')}
                       </span>
                     )}
                   </div>
@@ -229,7 +261,7 @@ export function LaborPresetsClient({
                       variant="ghost"
                       size="icon"
                       className="-mr-1 h-9 w-9 shrink-0"
-                      aria-label={t("actions.openMenu")}
+                      aria-label={t('actions.openMenu')}
                     >
                       <MoreVertical className="h-4 w-4" />
                     </Button>
@@ -237,19 +269,19 @@ export function LaborPresetsClient({
                   <DropdownMenuContent align="end">
                     <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
                       <Pencil className="mr-2 h-4 w-4" />
-                      {t("actions.edit")}
+                      {t('actions.edit')}
                     </DropdownMenuItem>
                     <DropdownMenuItem
                       className="text-destructive"
                       onClick={() => handleDelete(preset.id, preset.name)}
                     >
                       <Trash2 className="mr-2 h-4 w-4" />
-                      {t("actions.delete")}
+                      {t('actions.delete')}
                     </DropdownMenuItem>
                   </DropdownMenuContent>
                 </DropdownMenu>
               </div>
-            );
+            )
           })
         )}
       </div>
@@ -261,19 +293,27 @@ export function LaborPresetsClient({
           <TableHeader>
             <TableRow>
               <TableHead>
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
-                  {t("table.name")}
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('name')}
+                >
+                  {t('table.name')}
                   <SortIcon column="name" />
                 </button>
               </TableHead>
               <TableHead className="hidden sm:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("description")}>
-                  {t("table.description")}
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('description')}
+                >
+                  {t('table.description')}
                   <SortIcon column="description" />
                 </button>
               </TableHead>
-              <TableHead>{t("table.itemCount")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("table.type")}</TableHead>
+              <TableHead>{t('table.itemCount')}</TableHead>
+              <TableHead className="hidden md:table-cell">{t('table.type')}</TableHead>
               <TableHead className="w-[50px]" />
             </TableRow>
           </TableHeader>
@@ -281,81 +321,90 @@ export function LaborPresetsClient({
             {data.presets.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={5} className="h-32 text-center text-muted-foreground">
-                  {search ? t("empty.noMatch") : t("empty.noPresets")}
+                  {search ? t('empty.noMatch') : t('empty.noPresets')}
                 </TableCell>
               </TableRow>
             ) : (
               data.presets.map((preset) => (
                 <ContextMenu key={preset.id} modal={false}>
-                <ContextMenuTrigger asChild>
-                <TableRow
-                  className="cursor-pointer"
-                  {...interactiveRow(() => handleEdit(preset.id))}
-                >
-                  <TableCell className="font-medium">{preset.name}</TableCell>
-                  <TableCell className="hidden sm:table-cell text-muted-foreground">
-                    {preset.description || "-"}
-                  </TableCell>
-                  <TableCell>{preset._count.items}</TableCell>
-                  <TableCell className="hidden md:table-cell">
-                    {(() => {
-                      const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
-                      const serviceItems = preset.items.filter((i) => i.pricingType === "service");
-                      const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
-                      const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
-                      return (
-                        <div className="flex gap-1.5">
-                          {totalHours > 0 && (
-                            <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-emerald-500/30 bg-emerald-500/10 text-emerald-600">
-                              {totalHours} {t("table.hrs")}
-                            </span>
-                          )}
-                          {totalUnits > 0 && (
-                            <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-blue-500/30 bg-blue-500/10 text-blue-600">
-                              {totalUnits} {t("table.units")}
-                            </span>
-                          )}
-                        </div>
-                      );
-                    })()}
-                  </TableCell>
-                  <TableCell onClick={(e) => e.stopPropagation()}>
-                    <DropdownMenu>
-                      <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-8 w-8" aria-label={t("actions.openMenu")}>
-                          <MoreVertical className="h-4 w-4" />
-                        </Button>
-                      </DropdownMenuTrigger>
-                      <DropdownMenuContent align="end">
-                        <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
-                          <Pencil className="mr-2 h-4 w-4" />
-                          {t("actions.edit")}
-                        </DropdownMenuItem>
-                        <DropdownMenuItem
-                          className="text-destructive"
-                          onClick={() => handleDelete(preset.id, preset.name)}
-                        >
-                          <Trash2 className="mr-2 h-4 w-4" />
-                          {t("actions.delete")}
-                        </DropdownMenuItem>
-                      </DropdownMenuContent>
-                    </DropdownMenu>
-                  </TableCell>
-                </TableRow>
-                </ContextMenuTrigger>
-                <ContextMenuContent className="min-w-52">
-                  <ContextMenuItem onClick={() => handleEdit(preset.id)}>
-                    <Pencil className="mr-2 h-4 w-4" />
-                    {t("actions.edit")}
-                  </ContextMenuItem>
-                  <ContextMenuItem
-                    variant="destructive"
-                    onClick={() => handleDelete(preset.id, preset.name)}
-                  >
-                    <Trash2 className="mr-2 h-4 w-4" />
-                    {t("actions.delete")}
-                  </ContextMenuItem>
-                </ContextMenuContent>
+                  <ContextMenuTrigger asChild>
+                    <TableRow
+                      className="cursor-pointer"
+                      {...interactiveRow(() => handleEdit(preset.id))}
+                    >
+                      <TableCell className="font-medium">{preset.name}</TableCell>
+                      <TableCell className="hidden sm:table-cell text-muted-foreground">
+                        {preset.description || '-'}
+                      </TableCell>
+                      <TableCell>{preset._count.items}</TableCell>
+                      <TableCell className="hidden md:table-cell">
+                        {(() => {
+                          const hourlyItems = preset.items.filter(
+                            (i) => i.pricingType !== 'service'
+                          )
+                          const serviceItems = preset.items.filter(
+                            (i) => i.pricingType === 'service'
+                          )
+                          const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
+                          const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
+                          return (
+                            <div className="flex gap-1.5">
+                              {totalHours > 0 && (
+                                <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-emerald-500/30 bg-emerald-500/10 text-emerald-600">
+                                  {totalHours} {t('table.hrs')}
+                                </span>
+                              )}
+                              {totalUnits > 0 && (
+                                <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-blue-500/30 bg-blue-500/10 text-blue-600">
+                                  {totalUnits} {t('table.units')}
+                                </span>
+                              )}
+                            </div>
+                          )
+                        })()}
+                      </TableCell>
+                      <TableCell onClick={(e) => e.stopPropagation()}>
+                        <DropdownMenu>
+                          <DropdownMenuTrigger asChild>
+                            <Button
+                              variant="ghost"
+                              size="icon"
+                              className="h-8 w-8"
+                              aria-label={t('actions.openMenu')}
+                            >
+                              <MoreVertical className="h-4 w-4" />
+                            </Button>
+                          </DropdownMenuTrigger>
+                          <DropdownMenuContent align="end">
+                            <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
+                              <Pencil className="mr-2 h-4 w-4" />
+                              {t('actions.edit')}
+                            </DropdownMenuItem>
+                            <DropdownMenuItem
+                              className="text-destructive"
+                              onClick={() => handleDelete(preset.id, preset.name)}
+                            >
+                              <Trash2 className="mr-2 h-4 w-4" />
+                              {t('actions.delete')}
+                            </DropdownMenuItem>
+                          </DropdownMenuContent>
+                        </DropdownMenu>
+                      </TableCell>
+                    </TableRow>
+                  </ContextMenuTrigger>
+                  <ContextMenuContent className="min-w-52">
+                    <ContextMenuItem onClick={() => handleEdit(preset.id)}>
+                      <Pencil className="mr-2 h-4 w-4" />
+                      {t('actions.edit')}
+                    </ContextMenuItem>
+                    <ContextMenuItem
+                      variant="destructive"
+                      onClick={() => handleDelete(preset.id, preset.name)}
+                    >
+                      <Trash2 className="mr-2 h-4 w-4" />
+                      {t('actions.delete')}
+                    </ContextMenuItem>
+                  </ContextMenuContent>
                 </ContextMenu>
               ))
             )}
@@ -372,15 +421,17 @@ export function LaborPresetsClient({
       />
 
       <LaborPresetForm
-        key={editPreset?.id ?? "new"}
+        key={editPreset?.id ?? 'new'}
         open={showForm}
         onOpenChange={(open) => {
-          setShowForm(open);
-          if (!open) setEditPreset(null);
+          setShowForm(open)
+          if (!open) setEditPreset(null)
         }}
         preset={editPreset ?? undefined}
         defaultLaborRate={defaultLaborRate}
+        inventoryParts={inventoryParts}
+        currencyCode={currencyCode}
       />
     </div>
-  );
+  )
 }

+ 41 - 26
src/app/(authenticated)/labor-presets/page.tsx

@@ -1,48 +1,62 @@
-import { getLaborPresetsPaginated } from "@/features/labor-presets/Actions/laborPresetActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { LaborPresetsClient } from "./labor-presets-client";
-import { PageHeader } from "@/components/page-header";
+import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
+import { getLaborPresetsPaginated } from '@/features/labor-presets/Actions/laborPresetActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { LaborPresetsClient } from './labor-presets-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function LaborPresetsPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    sortBy?: string;
-    sortOrder?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
-  const [result, settingsResult] = await Promise.all([
+  const params = await searchParams
+  const [result, settingsResult, inventoryResult] = await Promise.all([
     getLaborPresetsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       sortBy: params.sortBy,
-      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
+      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.DEFAULT_LABOR_RATE]),
-  ]);
+    // For the "import from inventory" picker in the preset form. A user
+    // without inventory read permission simply gets no picker.
+    getInventoryPartsList(),
+  ])
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load labor presets"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load labor presets'}</p>
         </div>
       </>
-    );
+    )
   }
 
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0;
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const inventoryParts =
+    inventoryResult.success && inventoryResult.data
+      ? inventoryResult.data.map((p) => ({
+          id: p.id,
+          name: p.name,
+          partNumber: p.partNumber,
+          unit: p.unit,
+          sellPrice: p.sellPrice,
+          unitCost: p.unitCost,
+          quantity: p.quantity,
+        }))
+      : []
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
 
   return (
     <>
@@ -50,13 +64,14 @@ export default async function LaborPresetsPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <LaborPresetsClient
           data={result.data}
-          search={params.search || ""}
-          sortBy={params.sortBy || "updatedAt"}
-          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
+          search={params.search || ''}
+          sortBy={params.sortBy || 'updatedAt'}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
           currencyCode={currencyCode}
           defaultLaborRate={defaultLaborRate}
+          inventoryParts={inventoryParts}
         />
       </div>
     </>
-  );
+  )
 }

+ 6 - 1
src/app/(authenticated)/layout.tsx

@@ -171,7 +171,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
                       isAdminOrOwner={isOwnerOrAdmin}
                     />
                     <SidebarInset>
-                      <div className="pb-14 md:pb-0">{children}</div>
+                      {/* A flex column with a real height, so the `flex-1` every
+                          page already writes on its wrapper actually resolves.
+                          Without it a page that wants to fill the window (the
+                          work board's week timeline) stopped at its content and
+                          left the rest of the screen blank. */}
+                      <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">{children}</div>
                     </SidebarInset>
                     <SearchCommand />
                     {isOwnerOrAdmin && <NotificationInitializer />}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 498 - 374
src/app/(authenticated)/reports/reports-client.tsx


+ 157 - 121
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -1,14 +1,15 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { AppCard } from "@/components/app-card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
+import { AppCard } from '@/components/app-card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import {
   Command,
   CommandEmpty,
@@ -17,7 +18,7 @@ import {
   CommandItem,
   CommandList,
   CommandSeparator,
-} from "@/components/ui/command";
+} from '@/components/ui/command'
 import {
   AlertDialog,
   AlertDialogAction,
@@ -27,136 +28,138 @@ import {
   AlertDialogFooter,
   AlertDialogHeader,
   AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { toast } from "sonner";
-import { setSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { assignTechToUnassignedWorkOrders } from "@/features/workboard/Actions/technicianActions";
-import { Loader2, Ruler, Save, Wrench, Check, ChevronsUpDown, Plus } from "lucide-react";
+} from '@/components/ui/alert-dialog'
+import { toast } from 'sonner'
+import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
+import { Loader2, Ruler, Save, Wrench, Check, ChevronsUpDown, Plus } from 'lucide-react'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
-import { createTechnician } from "@/features/workboard/Actions/technicianActions";
-import { cn } from "@/lib/utils";
-import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
-import { ServiceTypeSelector } from "../company/service-type-selector";
+} from '@/components/ui/select'
+import { createTechnician } from '@/features/workboard/Actions/technicianActions'
+import { cn } from '@/lib/utils'
+import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
+import { ServiceTypeSelector } from '../company/service-type-selector'
 
 interface TechnicianOption {
   id: string
   name: string
 }
 
-export function WorkshopSettings({ settings, technicians: initialTechnicians = [] }: { settings: Record<string, string>; technicians?: TechnicianOption[] }) {
-  const router = useRouter();
-  const t = useTranslations('settings');
-  const [saving, setSaving] = useState(false);
+export function WorkshopSettings({
+  settings,
+  technicians: initialTechnicians = [],
+}: {
+  settings: Record<string, string>
+  technicians?: TechnicianOption[]
+}) {
+  const router = useRouter()
+  const t = useTranslations('settings')
+  const [saving, setSaving] = useState(false)
   const [serviceType, setServiceType] = useState(
-    settings[SETTING_KEYS.SERVICE_TYPE] || "automotive"
-  );
+    settings[SETTING_KEYS.SERVICE_TYPE] || 'automotive'
+  )
 
   const [defaultTechnicianId, setDefaultTechnicianId] = useState(
-    settings[SETTING_KEYS.DEFAULT_TECHNICIAN_ID] || ""
-  );
-  const [technicians, setTechnicians] = useState<TechnicianOption[]>(initialTechnicians);
-  const [techOpen, setTechOpen] = useState(false);
-  const [techSearch, setTechSearch] = useState('');
-  const [creatingTech, setCreatingTech] = useState(false);
-  const [showNewInput, setShowNewInput] = useState(false);
-  const [newTechName, setNewTechName] = useState('');
-  const [showAssignDialog, setShowAssignDialog] = useState(false);
-  const [pendingTechId, setPendingTechId] = useState<string | null>(null);
-  const [assigning, setAssigning] = useState(false);
+    settings[SETTING_KEYS.DEFAULT_TECHNICIAN_ID] || ''
+  )
+  const [technicians, setTechnicians] = useState<TechnicianOption[]>(initialTechnicians)
+  const [techOpen, setTechOpen] = useState(false)
+  const [techSearch, setTechSearch] = useState('')
+  const [creatingTech, setCreatingTech] = useState(false)
+  const [showNewInput, setShowNewInput] = useState(false)
+  const [newTechName, setNewTechName] = useState('')
+  const [showAssignDialog, setShowAssignDialog] = useState(false)
+  const [pendingTechId, setPendingTechId] = useState<string | null>(null)
+  const [assigning, setAssigning] = useState(false)
   const [defaultLaborRate, setDefaultLaborRate] = useState(
-    settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ""
-  );
-  const [unitSystem, setUnitSystem] = useState(
-    settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial"
-  );
+    settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
+  )
+  const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial')
+  const [defaultUnit, setDefaultUnit] = useState(
+    settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''
+  )
   const [workDayStart, setWorkDayStart] = useState(
-    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || "07:00"
-  );
+    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || '07:00'
+  )
   const [workDayEnd, setWorkDayEnd] = useState(
-    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || "15:00"
-  );
+    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || '15:00'
+  )
 
-  const selectedTechName = technicians.find((t) => t.id === defaultTechnicianId)?.name || "";
+  const selectedTechName = technicians.find((t) => t.id === defaultTechnicianId)?.name || ''
 
   const handleTechSelect = (techId: string) => {
-    setDefaultTechnicianId(techId);
-    setTechOpen(false);
+    setDefaultTechnicianId(techId)
+    setTechOpen(false)
     if (techId) {
-      setPendingTechId(techId);
-      setShowAssignDialog(true);
+      setPendingTechId(techId)
+      setShowAssignDialog(true)
     }
-  };
+  }
 
   const handleAssignUnassigned = async () => {
-    if (!pendingTechId) return;
-    setAssigning(true);
-    const result = await assignTechToUnassignedWorkOrders(pendingTechId);
-    setAssigning(false);
-    setShowAssignDialog(false);
-    setPendingTechId(null);
+    if (!pendingTechId) return
+    setAssigning(true)
+    const result = await assignTechToUnassignedWorkOrders(pendingTechId)
+    setAssigning(false)
+    setShowAssignDialog(false)
+    setPendingTechId(null)
     if (result.success && result.data) {
-      toast.success(t('workshop.assignedUnassigned', { count: result.data.updated }));
+      toast.success(t('workshop.assignedUnassigned', { count: result.data.updated }))
     } else {
-      toast.error(result.error || t('workshop.assignFailed'));
+      toast.error(result.error || t('workshop.assignFailed'))
     }
-  };
+  }
 
   const doCreateTechnician = async (name: string) => {
-    if (!name.trim()) return;
-    setCreatingTech(true);
-    const res = await createTechnician({ name: name.trim() });
-    setCreatingTech(false);
+    if (!name.trim()) return
+    setCreatingTech(true)
+    const res = await createTechnician({ name: name.trim() })
+    setCreatingTech(false)
     if (res.success && res.data) {
-      const newTech = { id: res.data.id, name: res.data.name };
-      setTechnicians((prev) => [...prev, newTech]);
-      setTechSearch('');
-      setNewTechName('');
-      setShowNewInput(false);
-      handleTechSelect(newTech.id);
+      const newTech = { id: res.data.id, name: res.data.name }
+      setTechnicians((prev) => [...prev, newTech])
+      setTechSearch('')
+      setNewTechName('')
+      setShowNewInput(false)
+      handleTechSelect(newTech.id)
     } else {
-      toast.error(t('workshop.failedCreateTech'));
+      toast.error(t('workshop.failedCreateTech'))
     }
-  };
+  }
 
-  const searchLower = techSearch.toLowerCase();
-  const exactMatch = technicians.some((tech) => tech.name.toLowerCase() === searchLower);
+  const searchLower = techSearch.toLowerCase()
+  const exactMatch = technicians.some((tech) => tech.name.toLowerCase() === searchLower)
 
   const handleSave = async () => {
-    setSaving(true);
+    setSaving(true)
     await setSettings({
       [SETTING_KEYS.SERVICE_TYPE]: serviceType,
       [SETTING_KEYS.DEFAULT_TECHNICIAN_ID]: defaultTechnicianId,
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
+      [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
-    });
-    setSaving(false);
-    router.refresh();
-    toast.success(t('workshop.saved'));
-  };
+    })
+    setSaving(false)
+    router.refresh()
+    toast.success(t('workshop.saved'))
+  }
 
   return (
     <div className="space-y-6">
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
-      <ServiceTypeSelector serviceType={serviceType} onServiceTypeChange={setServiceType} />
-      <AppCard
-        icon={Wrench}
-        title={t('workshop.title')}
-        contentClassName="space-y-6"
-      >
-          <p className="text-sm text-muted-foreground">
-            {t('workshop.description')}
-          </p>
+        <ServiceTypeSelector serviceType={serviceType} onServiceTypeChange={setServiceType} />
+        <AppCard icon={Wrench} title={t('workshop.title')} contentClassName="space-y-6">
+          <p className="text-sm text-muted-foreground">{t('workshop.description')}</p>
 
           <div className="grid gap-4 sm:grid-cols-2">
             <div className="space-y-2">
@@ -188,17 +191,19 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                         <CommandItem
                           value="__none__"
                           onSelect={() => {
-                            setDefaultTechnicianId("");
-                            setTechOpen(false);
+                            setDefaultTechnicianId('')
+                            setTechOpen(false)
                           }}
                         >
                           <Check
                             className={cn(
                               'mr-2 h-4 w-4',
-                              !defaultTechnicianId ? 'opacity-100' : 'opacity-0',
+                              !defaultTechnicianId ? 'opacity-100' : 'opacity-0'
                             )}
                           />
-                          <span className="text-muted-foreground">{t('workshop.noTechnician')}</span>
+                          <span className="text-muted-foreground">
+                            {t('workshop.noTechnician')}
+                          </span>
                         </CommandItem>
                         {technicians.map((tech) => (
                           <CommandItem
@@ -209,7 +214,7 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                             <Check
                               className={cn(
                                 'mr-2 h-4 w-4',
-                                defaultTechnicianId === tech.id ? 'opacity-100' : 'opacity-0',
+                                defaultTechnicianId === tech.id ? 'opacity-100' : 'opacity-0'
                               )}
                             />
                             {tech.name}
@@ -224,14 +229,19 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                             disabled={creatingTech}
                           >
                             <Plus className="mr-2 h-4 w-4" />
-                            {creatingTech ? t('workshop.creating') : t('workshop.createTechnician', { name: techSearch.trim() })}
+                            {creatingTech
+                              ? t('workshop.creating')
+                              : t('workshop.createTechnician', { name: techSearch.trim() })}
                           </CommandItem>
                         </CommandGroup>
                       )}
                       <CommandSeparator />
                       <CommandGroup>
                         {showNewInput ? (
-                          <div className="flex items-center gap-1.5 px-2 py-1.5" onKeyDown={(e) => e.stopPropagation()}>
+                          <div
+                            className="flex items-center gap-1.5 px-2 py-1.5"
+                            onKeyDown={(e) => e.stopPropagation()}
+                          >
                             <Input
                               autoFocus
                               placeholder={t('workshop.newTechPlaceholder')}
@@ -239,12 +249,12 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                               onChange={(e) => setNewTechName(e.target.value)}
                               onKeyDown={(e) => {
                                 if (e.key === 'Enter') {
-                                  e.preventDefault();
-                                  doCreateTechnician(newTechName);
+                                  e.preventDefault()
+                                  doCreateTechnician(newTechName)
                                 }
                                 if (e.key === 'Escape') {
-                                  setShowNewInput(false);
-                                  setNewTechName('');
+                                  setShowNewInput(false)
+                                  setNewTechName('')
                                 }
                               }}
                               className="h-7 text-sm"
@@ -261,10 +271,7 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                             </Button>
                           </div>
                         ) : (
-                          <CommandItem
-                            value="__add_new__"
-                            onSelect={() => setShowNewInput(true)}
-                          >
+                          <CommandItem value="__add_new__" onSelect={() => setShowNewInput(true)}>
                             <Plus className="mr-2 h-4 w-4" />
                             {t('workshop.addNewTech')}
                           </CommandItem>
@@ -315,9 +322,7 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
               <Ruler className="h-5 w-5 text-muted-foreground" />
               <h3 className="text-lg font-semibold">{t('workshop.unitsTitle')}</h3>
             </div>
-            <p className="text-sm text-muted-foreground">
-              {t('workshop.unitsDescription')}
-            </p>
+            <p className="text-sm text-muted-foreground">{t('workshop.unitsDescription')}</p>
 
             <div className="space-y-2">
               <Label htmlFor="unitSystem">{t('workshop.unitSystem')}</Label>
@@ -333,18 +338,44 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
             </div>
 
             <div className="rounded-lg border p-3 text-sm text-muted-foreground">
-              {unitSystem === "metric" ? (
+              {unitSystem === 'metric' ? (
                 <div className="space-y-1">
-                  <p>{t('workshop.distanceLabel')}: <span className="font-medium text-foreground">{t('workshop.kilometers')}</span></p>
-                  <p>{t('workshop.volumeLabel')}: <span className="font-medium text-foreground">{t('workshop.liters')}</span></p>
+                  <p>
+                    {t('workshop.distanceLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.kilometers')}</span>
+                  </p>
+                  <p>
+                    {t('workshop.volumeLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.liters')}</span>
+                  </p>
                 </div>
               ) : (
                 <div className="space-y-1">
-                  <p>{t('workshop.distanceLabel')}: <span className="font-medium text-foreground">{t('workshop.miles')}</span></p>
-                  <p>{t('workshop.volumeLabel')}: <span className="font-medium text-foreground">{t('workshop.gallons')}</span></p>
+                  <p>
+                    {t('workshop.distanceLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.miles')}</span>
+                  </p>
+                  <p>
+                    {t('workshop.volumeLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.gallons')}</span>
+                  </p>
                 </div>
               )}
             </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="defaultUnit">{t('workshop.defaultStockUnit')}</Label>
+              {/* Suggestions follow the (unsaved) system picked above, so
+                  switching to imperial immediately offers qt/gal/lb. */}
+              <UnitCombobox
+                id="defaultUnit"
+                className="w-48"
+                value={defaultUnit}
+                onChange={setDefaultUnit}
+                unitSystem={unitSystem}
+              />
+              <p className="text-sm text-muted-foreground">{t('workshop.defaultStockUnitHint')}</p>
+            </div>
           </div>
 
           <SaveButton>
@@ -363,15 +394,20 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
         </AppCard>
       </ReadOnlyWrapper>
 
-      <AlertDialog open={showAssignDialog} onOpenChange={(open) => {
-        if (!open) setPendingTechId(null);
-        setShowAssignDialog(open);
-      }}>
+      <AlertDialog
+        open={showAssignDialog}
+        onOpenChange={(open) => {
+          if (!open) setPendingTechId(null)
+          setShowAssignDialog(open)
+        }}
+      >
         <AlertDialogContent>
           <AlertDialogHeader>
             <AlertDialogTitle>{t('workshop.assignDialogTitle')}</AlertDialogTitle>
             <AlertDialogDescription>
-              {t('workshop.assignDialogDescription', { name: technicians.find((tech) => tech.id === pendingTechId)?.name || '' })}
+              {t('workshop.assignDialogDescription', {
+                name: technicians.find((tech) => tech.id === pendingTechId)?.name || '',
+              })}
             </AlertDialogDescription>
           </AlertDialogHeader>
           <AlertDialogFooter>
@@ -384,5 +420,5 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
         </AlertDialogContent>
       </AlertDialog>
     </div>
-  );
+  )
 }

+ 8 - 1
src/app/(authenticated)/vehicles/[id]/service/new/page.tsx

@@ -61,7 +61,14 @@ export default async function NewServicePage({
   }
 
   const boardTechId = query.boardTech
-  const result = await createDraftServiceRecord(id, startDateTime, endDateTime, boardTechId)
+  const boardBayId = query.boardBay
+  const result = await createDraftServiceRecord(
+    id,
+    startDateTime,
+    endDateTime,
+    boardTechId,
+    boardBayId
+  )
 
   if (result.success && result.data?.id) {
     redirect(`/vehicles/${id}/service/${result.data.id}`)

+ 45 - 31
src/app/(authenticated)/work-board/page.tsx

@@ -1,33 +1,40 @@
-import { getTranslations } from "next-intl/server";
-import { PageHeader } from "@/components/page-header";
-import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
-import { getBoardJobs, getUnassignedJobs, getWorkBoardSettings } from "@/features/workboard/Actions/boardActions";
-import { WorkBoardClient } from "@/features/workboard/Components/WorkBoardClient";
+import { getTranslations } from 'next-intl/server'
+import { PageHeader } from '@/components/page-header'
+import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
+import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
+import {
+  getBoardJobs,
+  getUnassignedJobs,
+  getWorkBoardSettings,
+} from '@/features/workboard/Actions/boardActions'
+import { WorkBoardClient } from '@/features/workboard/Components/WorkBoardClient'
 
 function getWeekStart(date: Date, weekStartDay: number): string {
-  const d = new Date(date);
-  const currentDay = d.getDay();
-  const diff = (currentDay - weekStartDay + 7) % 7;
-  d.setDate(d.getDate() - diff);
-  const y = d.getFullYear();
-  const m = String(d.getMonth() + 1).padStart(2, "0");
-  const dd = String(d.getDate()).padStart(2, "0");
-  return `${y}-${m}-${dd}`;
+  const d = new Date(date)
+  const currentDay = d.getDay()
+  const diff = (currentDay - weekStartDay + 7) % 7
+  d.setDate(d.getDate() - diff)
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const dd = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${dd}`
 }
 
 export default async function WorkBoardPage() {
-  const settingsResult = await getWorkBoardSettings();
-  const boardSettings = settingsResult.success && settingsResult.data
-    ? settingsResult.data
-    : { weekStartDay: 1, workDayStart: "07:00", workDayEnd: "15:00" };
+  const settingsResult = await getWorkBoardSettings()
+  const boardSettings =
+    settingsResult.success && settingsResult.data
+      ? settingsResult.data
+      : { weekStartDay: 1, workDayStart: '07:00', workDayEnd: '15:00' }
 
-  const weekStart = getWeekStart(new Date(), boardSettings.weekStartDay);
+  const weekStart = getWeekStart(new Date(), boardSettings.weekStartDay)
 
-  const [techResult, assignResult, unassignedResult] = await Promise.all([
+  const [techResult, bayResult, assignResult, unassignedResult] = await Promise.all([
     getTechnicians(),
+    getWorkBays(),
     getBoardJobs(weekStart),
     getUnassignedJobs(),
-  ]);
+  ])
 
   if (!techResult.success) {
     return (
@@ -35,31 +42,38 @@ export default async function WorkBoardPage() {
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
           <p className="text-muted-foreground">
-            {techResult.error || (await getTranslations("workBoard.page"))("error")}
+            {techResult.error || (await getTranslations('workBoard.page'))('error')}
           </p>
         </div>
       </>
-    );
+    )
   }
 
-  const technicians = techResult.data ?? [];
-  const assignments = assignResult.success && assignResult.data ? assignResult.data : [];
-  const unassigned = unassignedResult.success && unassignedResult.data
-    ? unassignedResult.data
-    : { serviceRecords: [], inspections: [] };
+  const technicians = techResult.data ?? []
+  const workBays = bayResult.success && bayResult.data ? bayResult.data : []
+  const assignments = assignResult.success && assignResult.data ? assignResult.data : []
+  const unassigned =
+    unassignedResult.success && unassignedResult.data
+      ? unassignedResult.data
+      : { serviceRecords: [], inspections: [] }
 
   return (
     <>
       <PageHeader />
-      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+      <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 pt-0">
         <WorkBoardClient
-          initialTechnicians={technicians as Parameters<typeof WorkBoardClient>[0]["initialTechnicians"]}
-          initialAssignments={assignments as Parameters<typeof WorkBoardClient>[0]["initialAssignments"]}
+          initialTechnicians={
+            technicians as Parameters<typeof WorkBoardClient>[0]['initialTechnicians']
+          }
+          initialWorkBays={workBays}
+          initialAssignments={
+            assignments as Parameters<typeof WorkBoardClient>[0]['initialAssignments']
+          }
           initialUnassigned={unassigned}
           initialWeekStart={weekStart}
           boardSettings={boardSettings}
         />
       </div>
     </>
-  );
+  )
 }

+ 36 - 32
src/app/(presenter)/work-board/presenter/page.tsx

@@ -1,70 +1,74 @@
-import { Suspense } from "react";
-import { getTranslations } from "next-intl/server";
-import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
-import { getBoardJobs, getWorkBoardSettings } from "@/features/workboard/Actions/boardActions";
-import { WorkBoardPresenter } from "@/features/workboard/Components/WorkBoardPresenter";
+import { Suspense } from 'react'
+import { getTranslations } from 'next-intl/server'
+import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
+import { getBoardJobs, getWorkBoardSettings } from '@/features/workboard/Actions/boardActions'
+import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
+import { WorkBoardPresenter } from '@/features/workboard/Components/WorkBoardPresenter'
 
-export const dynamic = "force-dynamic";
+export const dynamic = 'force-dynamic'
 
 function getWeekStart(date: Date, weekStartDay: number): string {
-  const d = new Date(date);
-  const diff = (d.getDay() - weekStartDay + 7) % 7;
-  d.setDate(d.getDate() - diff);
-  const y = d.getFullYear();
-  const m = String(d.getMonth() + 1).padStart(2, "0");
-  const dd = String(d.getDate()).padStart(2, "0");
-  return `${y}-${m}-${dd}`;
+  const d = new Date(date)
+  const diff = (d.getDay() - weekStartDay + 7) % 7
+  d.setDate(d.getDate() - diff)
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const dd = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${dd}`
 }
 
 export default async function WorkBoardPresenterPage({
   searchParams,
 }: {
-  searchParams: Promise<{ date?: string }>;
+  searchParams: Promise<{ date?: string }>
 }) {
-  const params = await searchParams;
-  const baseDate = params.date ? new Date(params.date + "T12:00:00") : new Date();
+  const params = await searchParams
+  const baseDate = params.date ? new Date(params.date + 'T12:00:00') : new Date()
 
-  const techPromise = getTechnicians();
-  const settingsResult = await getWorkBoardSettings();
+  const techPromise = getTechnicians()
+  const settingsResult = await getWorkBoardSettings()
 
-  const boardSettings = settingsResult.success && settingsResult.data
-    ? settingsResult.data
-    : { weekStartDay: 1, workDayStart: "07:00", workDayEnd: "15:00" };
+  const boardSettings =
+    settingsResult.success && settingsResult.data
+      ? settingsResult.data
+      : { weekStartDay: 1, workDayStart: '07:00', workDayEnd: '15:00' }
 
-  const weekStart = getWeekStart(baseDate, boardSettings.weekStartDay);
-  const [techResult, assignResult] = await Promise.all([
+  const weekStart = getWeekStart(baseDate, boardSettings.weekStartDay)
+  const [techResult, assignResult, bayResult] = await Promise.all([
     techPromise,
     getBoardJobs(weekStart),
-  ]);
+    getWorkBays(),
+  ])
 
   if (!techResult.success) {
     return (
       <div className="flex h-screen items-center justify-center">
         <p className="text-muted-foreground text-lg">
-          {techResult.error || (await getTranslations("workBoard.page"))("error")}
+          {techResult.error || (await getTranslations('workBoard.page'))('error')}
         </p>
       </div>
-    );
+    )
   }
 
-  const technicians = techResult.data ?? [];
-  const assignments =
-    assignResult.success && assignResult.data ? assignResult.data : [];
+  const technicians = techResult.data ?? []
+  const workBays = bayResult.success && bayResult.data ? bayResult.data : []
+  const assignments = assignResult.success && assignResult.data ? assignResult.data : []
 
   return (
     <Suspense>
       <WorkBoardPresenter
         initialTechnicians={
-          technicians as Parameters<typeof WorkBoardPresenter>[0]["initialTechnicians"]
+          technicians as Parameters<typeof WorkBoardPresenter>[0]['initialTechnicians']
         }
         initialAssignments={
-          assignments as Parameters<typeof WorkBoardPresenter>[0]["initialAssignments"]
+          assignments as Parameters<typeof WorkBoardPresenter>[0]['initialAssignments']
         }
+        initialWorkBays={workBays}
         initialWeekStart={weekStart}
         workDayStart={boardSettings.workDayStart}
         workDayEnd={boardSettings.workDayEnd}
         weekStartDay={boardSettings.weekStartDay}
       />
     </Suspense>
-  );
+  )
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1001 - 523
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx


+ 115 - 104
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -1,35 +1,35 @@
-import { db } from "@/lib/db";
-import { notFound } from "next/navigation";
-import { InvoiceView } from "./invoice-view";
-import { getFeatures } from "@/lib/features";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-import { mergeWithDefaults } from "@/features/settings/Schema/invoiceLayoutSchema";
-import type { Metadata } from "next";
+import { db } from '@/lib/db'
+import { notFound } from 'next/navigation'
+import { InvoiceView } from './invoice-view'
+import { getFeatures } from '@/lib/features'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { Metadata } from 'next'
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 function toPublicFileUrl(fileUrl: string, token: string): string {
-  const match = fileUrl.match(/^\/api\/protected\/files\/[^/]+\/(.+)$/);
-  if (match) return `/api/public/files/${token}/${match[1]}`;
+  const match = fileUrl.match(/^\/api\/protected\/files\/[^/]+\/(.+)$/)
+  if (match) return `/api/public/files/${token}/${match[1]}`
   // Legacy URLs pass through as-is
-  return fileUrl;
+  return fileUrl
 }
 
-export const revalidate = 60;
+export const revalidate = 60
 
 export const metadata: Metadata = {
   robots: { index: false, follow: false },
-};
+}
 
 export default async function PublicInvoicePage({
   params,
 }: {
-  params: Promise<{ orgId: string; token: string }>;
+  params: Promise<{ orgId: string; token: string }>
 }) {
-  const { orgId: orgParam, token } = await params;
+  const { orgId: orgParam, token } = await params
 
   // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-  const resolvedOrg = await resolvePortalOrg(orgParam);
-  const orgId = resolvedOrg?.id ?? orgParam;
+  const resolvedOrg = await resolvePortalOrg(orgParam)
+  const orgId = resolvedOrg?.id ?? orgParam
 
   const record = await db.serviceRecord.findUnique({
     where: { publicToken: token },
@@ -42,13 +42,14 @@ export default async function PublicInvoicePage({
           partNumber: true,
           name: true,
           quantity: true,
+          unit: true,
           unitPrice: true,
           total: true,
         },
       },
       laborItems: true,
       attachments: true,
-      payments: { orderBy: { date: "desc" } },
+      payments: { orderBy: { date: 'desc' } },
       customer: {
         select: {
           name: true,
@@ -82,10 +83,10 @@ export default async function PublicInvoicePage({
         },
       },
     },
-  });
+  })
 
   if (!record || record.organizationId !== orgId) {
-    notFound();
+    notFound()
   }
 
   // Fetch workshop settings and features
@@ -95,34 +96,34 @@ export default async function PublicInvoicePage({
         organizationId: record.organizationId,
         key: {
           in: [
-            "workshop.address",
-            "workshop.phone",
-            "workshop.email",
-            "workshop.logo",
-            "workshop.currencyCode",
-            "workshop.currencyFormat",
-            "invoice.bankAccount",
-            "invoice.orgNumber",
-            "invoice.paymentTerms",
-            "invoice.footerNote",
-            "invoice.showBankAccount",
-            "invoice.showOrgNumber",
-            "invoice.dueDays",
-            "invoice.showLogo",
-            "invoice.showCompanyName",
-            "invoice.primaryColor",
-            "invoice.headerStyle",
-            "invoice.logoSize",
-            "payment.providersEnabled",
-            "payment.termsOfSale",
-            "payment.termsOfSaleUrl",
-            "workshop.dateFormat",
-            "workshop.timezone",
-            "workshop.serviceType",
-            "workshop.taxLabel",
-            "portal.enabled",
-            "invoice.layoutConfig",
-            "telegram.botUsername",
+            'workshop.address',
+            'workshop.phone',
+            'workshop.email',
+            'workshop.logo',
+            'workshop.currencyCode',
+            'workshop.currencyFormat',
+            'invoice.bankAccount',
+            'invoice.orgNumber',
+            'invoice.paymentTerms',
+            'invoice.footerNote',
+            'invoice.showBankAccount',
+            'invoice.showOrgNumber',
+            'invoice.dueDays',
+            'invoice.showLogo',
+            'invoice.showCompanyName',
+            'invoice.primaryColor',
+            'invoice.headerStyle',
+            'invoice.logoSize',
+            'payment.providersEnabled',
+            'payment.termsOfSale',
+            'payment.termsOfSaleUrl',
+            'workshop.dateFormat',
+            'workshop.timezone',
+            'workshop.serviceType',
+            'workshop.taxLabel',
+            'portal.enabled',
+            'invoice.layoutConfig',
+            'telegram.botUsername',
           ],
         },
       },
@@ -134,60 +135,70 @@ export default async function PublicInvoicePage({
         })
       : null,
     getFeatures(orgId),
-  ]);
+  ])
 
   // Fetch findings for this service record (open ones to show on invoice)
   const findings = await db.vehicleFinding.findMany({
-    where: { serviceRecordId: record.id, status: { not: "resolved" } },
+    where: { serviceRecordId: record.id, status: { not: 'resolved' } },
     select: { description: true, severity: true, notes: true },
-    orderBy: { createdAt: "desc" },
-  });
+    orderBy: { createdAt: 'desc' },
+  })
 
   // Fetch custom field values for this service record
   const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: record.id, entityType: "service_record" },
-    include: { field: { select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true } } },
-    orderBy: { field: { sortOrder: "asc" } },
-  });
+    where: { entityId: record.id, entityType: 'service_record' },
+    include: {
+      field: {
+        select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true },
+      },
+    },
+    orderBy: { field: { sortOrder: 'asc' } },
+  })
 
   const customFields = customFieldValues
-    .filter(v => v.field.isActive && v.value)
-    .map(v => ({ label: v.field.label, value: v.value, fieldType: v.field.fieldType, fieldId: v.field.id }));
+    .filter((v) => v.field.isActive && v.value)
+    .map((v) => ({
+      label: v.field.label,
+      value: v.value,
+      fieldType: v.field.fieldType,
+      fieldId: v.field.id,
+    }))
 
-  const settingsMap: Record<string, string> = {};
-  for (const s of settings) settingsMap[s.key] = s.value;
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
 
   const workshop = {
-    name: org?.name || "",
-    address: settingsMap["workshop.address"] || "",
-    phone: settingsMap["workshop.phone"] || "",
-    email: settingsMap["workshop.email"] || "",
-  };
+    name: org?.name || '',
+    address: settingsMap['workshop.address'] || '',
+    phone: settingsMap['workshop.phone'] || '',
+    email: settingsMap['workshop.email'] || '',
+  }
 
-  const currencyCode = settingsMap["workshop.currencyCode"] || "USD";
-  const currencyFormat: "symbol" | "code" = settingsMap["workshop.currencyFormat"] === "code" ? "code" : "symbol";
+  const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
+  const currencyFormat: 'symbol' | 'code' =
+    settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
 
   const invoiceSettings = {
-    bankAccount: settingsMap["invoice.bankAccount"] || "",
-    orgNumber: settingsMap["invoice.orgNumber"] || "",
-    paymentTerms: settingsMap["invoice.paymentTerms"] || "",
-    footerNote: settingsMap["invoice.footerNote"] || "",
-    showBankAccount: settingsMap["invoice.showBankAccount"] !== "false",
-    showOrgNumber: settingsMap["invoice.showOrgNumber"] !== "false",
-    dueDays: Number(settingsMap["invoice.dueDays"]) || 0,
-  };
-
-  const showLogo = settingsMap["invoice.showLogo"] !== "false";
-  const showCompanyName = settingsMap["invoice.showCompanyName"] !== "false";
-  const rawLogoUrl = settingsMap["workshop.logo"] || "";
-  const logoUrl = rawLogoUrl ? toPublicFileUrl(rawLogoUrl, token) : "";
+    bankAccount: settingsMap['invoice.bankAccount'] || '',
+    orgNumber: settingsMap['invoice.orgNumber'] || '',
+    paymentTerms: settingsMap['invoice.paymentTerms'] || '',
+    footerNote: settingsMap['invoice.footerNote'] || '',
+    showBankAccount: settingsMap['invoice.showBankAccount'] !== 'false',
+    showOrgNumber: settingsMap['invoice.showOrgNumber'] !== 'false',
+    dueDays: Number(settingsMap['invoice.dueDays']) || 0,
+  }
+
+  const showLogo = settingsMap['invoice.showLogo'] !== 'false'
+  const showCompanyName = settingsMap['invoice.showCompanyName'] !== 'false'
+  const rawLogoUrl = settingsMap['workshop.logo'] || ''
+  const logoUrl = rawLogoUrl ? toPublicFileUrl(rawLogoUrl, token) : ''
 
   // Determine which online payment providers are enabled for this org
-  const enabledProvidersRaw = settingsMap["payment.providersEnabled"] || "";
+  const enabledProvidersRaw = settingsMap['payment.providersEnabled'] || ''
   const enabledProviders = enabledProvidersRaw
-    .split(",")
+    .split(',')
     .map((s) => s.trim())
-    .filter(Boolean);
+    .filter(Boolean)
 
   // Rewrite attachment file URLs to use the public file route (no auth required)
   // Only include attachments marked for invoice display
@@ -199,26 +210,26 @@ export default async function PublicInvoicePage({
         ...att,
         fileUrl: toPublicFileUrl(att.fileUrl, token),
       })),
-  };
+  }
 
   // Parse layout config
   const layoutConfig = mergeWithDefaults(
-    settingsMap["invoice.layoutConfig"] ? JSON.parse(settingsMap["invoice.layoutConfig"]) : {}
-  );
+    settingsMap['invoice.layoutConfig'] ? JSON.parse(settingsMap['invoice.layoutConfig']) : {}
+  )
 
-  const termsOfSaleUrl = settingsMap["payment.termsOfSaleUrl"]
-    || (settingsMap["payment.termsOfSale"] ? `/share/terms/${orgId}` : undefined);
+  const termsOfSaleUrl =
+    settingsMap['payment.termsOfSaleUrl'] ||
+    (settingsMap['payment.termsOfSale'] ? `/share/terms/${orgId}` : undefined)
 
-  const appUrl = process.env.NEXT_PUBLIC_APP_URL
-    || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
-  const portalSlug = org?.portalSlug;
-  const portalEnabled = settingsMap["portal.enabled"] === "true";
-  const portalUrl = portalEnabled
-    ? `${appUrl}/portal/${portalSlug || orgId}`
-    : undefined;
+  const appUrl =
+    process.env.NEXT_PUBLIC_APP_URL ||
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const portalSlug = org?.portalSlug
+  const portalEnabled = settingsMap['portal.enabled'] === 'true'
+  const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
-  const telegramBotUsername = settingsMap["telegram.botUsername"] || "";
-  const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined;
+  const telegramBotUsername = settingsMap['telegram.botUsername'] || ''
+  const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined
 
   return (
     <InvoiceView
@@ -234,19 +245,19 @@ export default async function PublicInvoicePage({
       showLogo={showLogo}
       showCompanyName={showCompanyName}
       showTorqvoiceBranding={!features.brandingRemoved}
-      dateFormat={settingsMap["workshop.dateFormat"] || undefined}
-      timezone={settingsMap["workshop.timezone"] || undefined}
+      dateFormat={settingsMap['workshop.dateFormat'] || undefined}
+      timezone={settingsMap['workshop.timezone'] || undefined}
       termsOfSaleUrl={termsOfSaleUrl}
-      primaryColor={settingsMap["invoice.primaryColor"] || "#d97706"}
-      headerStyle={settingsMap["invoice.headerStyle"] || "standard"}
-      logoSize={Number(settingsMap["invoice.logoSize"]) || 100}
+      primaryColor={settingsMap['invoice.primaryColor'] || '#d97706'}
+      headerStyle={settingsMap['invoice.headerStyle'] || 'standard'}
+      logoSize={Number(settingsMap['invoice.logoSize']) || 100}
       portalUrl={portalUrl}
       layoutConfig={layoutConfig}
       customFields={customFields}
       findings={findings}
       telegramBotLink={telegramBotLink}
-      serviceType={(settingsMap["workshop.serviceType"] || "automotive") as "automotive" | "marine"}
-      taxLabel={settingsMap["workshop.taxLabel"]?.trim() || undefined}
+      serviceType={(settingsMap['workshop.serviceType'] || 'automotive') as 'automotive' | 'marine'}
+      taxLabel={settingsMap['workshop.taxLabel']?.trim() || undefined}
     />
-  );
+  )
 }

+ 86 - 74
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -1,27 +1,27 @@
-import { db } from "@/lib/db";
-import { notFound } from "next/navigation";
-import { QuoteView } from "./quote-view";
-import { getFeatures } from "@/lib/features";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-import { mergeWithDefaults } from "@/features/settings/Schema/invoiceLayoutSchema";
-import type { Metadata } from "next";
+import { db } from '@/lib/db'
+import { notFound } from 'next/navigation'
+import { QuoteView } from './quote-view'
+import { getFeatures } from '@/lib/features'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { Metadata } from 'next'
 
-export const revalidate = 60;
+export const revalidate = 60
 
 export const metadata: Metadata = {
   robots: { index: false, follow: false },
-};
+}
 
 export default async function PublicQuotePage({
   params,
 }: {
-  params: Promise<{ orgId: string; token: string }>;
+  params: Promise<{ orgId: string; token: string }>
 }) {
-  const { orgId: orgParam, token } = await params;
+  const { orgId: orgParam, token } = await params
 
   // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-  const resolvedOrg = await resolvePortalOrg(orgParam);
-  const orgId = resolvedOrg?.id ?? orgParam;
+  const resolvedOrg = await resolvePortalOrg(orgParam)
+  const orgId = resolvedOrg?.id ?? orgParam
 
   const quote = await db.quote.findFirst({
     where: { publicToken: token, organizationId: orgId },
@@ -34,6 +34,7 @@ export default async function PublicQuotePage({
           partNumber: true,
           name: true,
           quantity: true,
+          unit: true,
           unitPrice: true,
           total: true,
           excluded: true,
@@ -61,10 +62,10 @@ export default async function PublicQuotePage({
         },
       },
     },
-  });
+  })
 
   if (!quote) {
-    notFound();
+    notFound()
   }
 
   const [settings, org, features] = await Promise.all([
@@ -73,23 +74,23 @@ export default async function PublicQuotePage({
         organizationId: orgId,
         key: {
           in: [
-            "workshop.address",
-            "workshop.phone",
-            "workshop.email",
-            "workshop.logo",
-            "workshop.currencyCode",
-            "workshop.currencyFormat",
-            "workshop.dateFormat",
-            "workshop.timezone",
-            "quote.primaryColor",
-            "quote.headerStyle",
-            "quote.logoSize",
-            "invoice.primaryColor",
-            "invoice.headerStyle",
-            "portal.enabled",
-            "quote.layoutConfig",
-            "workshop.serviceType",
-            "workshop.taxLabel",
+            'workshop.address',
+            'workshop.phone',
+            'workshop.email',
+            'workshop.logo',
+            'workshop.currencyCode',
+            'workshop.currencyFormat',
+            'workshop.dateFormat',
+            'workshop.timezone',
+            'quote.primaryColor',
+            'quote.headerStyle',
+            'quote.logoSize',
+            'invoice.primaryColor',
+            'invoice.headerStyle',
+            'portal.enabled',
+            'quote.layoutConfig',
+            'workshop.serviceType',
+            'workshop.taxLabel',
           ],
         },
       },
@@ -99,76 +100,87 @@ export default async function PublicQuotePage({
       select: { name: true, portalSlug: true },
     }),
     getFeatures(orgId),
-  ]);
+  ])
 
   // Fetch custom field values for this quote
   const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: quote.id, entityType: "quote" },
-    include: { field: { select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true } } },
-    orderBy: { field: { sortOrder: "asc" } },
-  });
+    where: { entityId: quote.id, entityType: 'quote' },
+    include: {
+      field: {
+        select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true },
+      },
+    },
+    orderBy: { field: { sortOrder: 'asc' } },
+  })
 
   const customFields = customFieldValues
-    .filter(v => v.field.isActive && v.value)
-    .map(v => ({ label: v.field.label, value: v.value, fieldType: v.field.fieldType, fieldId: v.field.id }));
+    .filter((v) => v.field.isActive && v.value)
+    .map((v) => ({
+      label: v.field.label,
+      value: v.value,
+      fieldType: v.field.fieldType,
+      fieldId: v.field.id,
+    }))
 
-  const settingsMap: Record<string, string> = {};
-  for (const s of settings) settingsMap[s.key] = s.value;
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
 
   const workshop = {
-    name: org?.name || "",
-    address: settingsMap["workshop.address"] || "",
-    phone: settingsMap["workshop.phone"] || "",
-    email: settingsMap["workshop.email"] || "",
-  };
+    name: org?.name || '',
+    address: settingsMap['workshop.address'] || '',
+    phone: settingsMap['workshop.phone'] || '',
+    email: settingsMap['workshop.email'] || '',
+  }
 
-  const currencyCode = settingsMap["workshop.currencyCode"] || "USD";
-  const currencyFormat: "symbol" | "code" = settingsMap["workshop.currencyFormat"] === "code" ? "code" : "symbol";
+  const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
+  const currencyFormat: 'symbol' | 'code' =
+    settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
 
   // Rewrite logo URL for public access
-  const rawLogoUrl = settingsMap["workshop.logo"] || "";
-  let logoUrl = "";
+  const rawLogoUrl = settingsMap['workshop.logo'] || ''
+  let logoUrl = ''
   if (rawLogoUrl) {
-    const match = rawLogoUrl.match(/^\/api\/files\/[^/]+\/(.+)$/);
-    if (match) logoUrl = `/api/public/files/${token}/${match[1]}`;
-    else logoUrl = rawLogoUrl;
+    const match = rawLogoUrl.match(/^\/api\/files\/[^/]+\/(.+)$/)
+    if (match) logoUrl = `/api/public/files/${token}/${match[1]}`
+    else logoUrl = rawLogoUrl
   }
 
   // Rewrite attachment URLs for public access
   const imageAttachments = (quote.attachments || [])
-    .filter((a) => a.category === "image")
+    .filter((a) => a.category === 'image')
     .map((a) => ({
       ...a,
       fileUrl: a.fileUrl.replace(
         /^\/api\/protected\/files\/[^/]+\//,
         `/api/public/files/${token}/`
       ),
-    }));
+    }))
   const documentAttachments = (quote.attachments || [])
-    .filter((a) => a.category === "document")
+    .filter((a) => a.category === 'document')
     .map((a) => ({
       ...a,
       fileUrl: a.fileUrl.replace(
         /^\/api\/protected\/files\/[^/]+\//,
         `/api/public/files/${token}/`
       ),
-    }));
+    }))
 
   // Parse layout config
   const layoutConfig = mergeWithDefaults(
-    settingsMap["quote.layoutConfig"] ? JSON.parse(settingsMap["quote.layoutConfig"]) : {}
-  );
+    settingsMap['quote.layoutConfig'] ? JSON.parse(settingsMap['quote.layoutConfig']) : {}
+  )
 
-  const primaryColor = settingsMap["quote.primaryColor"] || settingsMap["invoice.primaryColor"] || "#d97706";
-  const headerStyle = settingsMap["quote.headerStyle"] || settingsMap["invoice.headerStyle"] || "standard";
+  const primaryColor =
+    settingsMap['quote.primaryColor'] || settingsMap['invoice.primaryColor'] || '#d97706'
+  const headerStyle =
+    settingsMap['quote.headerStyle'] || settingsMap['invoice.headerStyle'] || 'standard'
 
-  const appUrl = process.env.NEXT_PUBLIC_APP_URL
-    || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
-  const portalSlug = org?.portalSlug;
-  const portalEnabled = settingsMap["portal.enabled"] === "true";
-  const portalUrl = portalEnabled
-    ? `${appUrl}/portal/${portalSlug || orgId}`
-    : undefined;
+  const appUrl =
+    process.env.NEXT_PUBLIC_APP_URL ||
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const portalSlug = org?.portalSlug
+  const portalEnabled = settingsMap['portal.enabled'] === 'true'
+  const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
   return (
     <QuoteView
@@ -180,18 +192,18 @@ export default async function PublicQuotePage({
       token={token}
       logoUrl={logoUrl}
       showTorqvoiceBranding={!features.brandingRemoved}
-      dateFormat={settingsMap["workshop.dateFormat"] || undefined}
-      timezone={settingsMap["workshop.timezone"] || undefined}
+      dateFormat={settingsMap['workshop.dateFormat'] || undefined}
+      timezone={settingsMap['workshop.timezone'] || undefined}
       primaryColor={primaryColor}
       headerStyle={headerStyle}
-      logoSize={Number(settingsMap["quote.logoSize"]) || 100}
+      logoSize={Number(settingsMap['quote.logoSize']) || 100}
       portalUrl={portalUrl}
       imageAttachments={imageAttachments}
       documentAttachments={documentAttachments}
       layoutConfig={layoutConfig}
       customFields={customFields}
-      serviceType={(settingsMap["workshop.serviceType"] || "automotive") as "automotive" | "marine"}
-      taxLabel={settingsMap["workshop.taxLabel"]?.trim() || undefined}
+      serviceType={(settingsMap['workshop.serviceType'] || 'automotive') as 'automotive' | 'marine'}
+      taxLabel={settingsMap['workshop.taxLabel']?.trim() || undefined}
     />
-  );
+  )
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 928 - 547
src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است