Просмотр исходного кода

Clock in and out from the web, a timesheet page for managers, and clocked time on work orders and the technician report (#348)

Bernt Christian Egeland 3 недель назад
Родитель
Сommit
056853e6c4
100 измененных файлов с 6248 добавлено и 202 удалено
  1. 9 2
      messages/de/audit.json
  2. 2 1
      messages/de/dashboard.json
  3. 2 0
      messages/de/navigation.json
  4. 1 0
      messages/de/permissions.json
  5. 21 2
      messages/de/reports.json
  6. 141 0
      messages/de/timeTracking.json
  7. 9 2
      messages/en/audit.json
  8. 2 1
      messages/en/dashboard.json
  9. 2 0
      messages/en/navigation.json
  10. 1 0
      messages/en/permissions.json
  11. 21 2
      messages/en/reports.json
  12. 141 0
      messages/en/timeTracking.json
  13. 9 2
      messages/es/audit.json
  14. 2 1
      messages/es/dashboard.json
  15. 2 0
      messages/es/navigation.json
  16. 1 0
      messages/es/permissions.json
  17. 21 2
      messages/es/reports.json
  18. 141 0
      messages/es/timeTracking.json
  19. 9 2
      messages/fr/audit.json
  20. 2 1
      messages/fr/dashboard.json
  21. 2 0
      messages/fr/navigation.json
  22. 1 0
      messages/fr/permissions.json
  23. 21 2
      messages/fr/reports.json
  24. 141 0
      messages/fr/timeTracking.json
  25. 9 2
      messages/it/audit.json
  26. 2 1
      messages/it/dashboard.json
  27. 2 0
      messages/it/navigation.json
  28. 1 0
      messages/it/permissions.json
  29. 21 2
      messages/it/reports.json
  30. 141 0
      messages/it/timeTracking.json
  31. 9 2
      messages/lt/audit.json
  32. 2 1
      messages/lt/dashboard.json
  33. 2 0
      messages/lt/navigation.json
  34. 1 0
      messages/lt/permissions.json
  35. 21 2
      messages/lt/reports.json
  36. 141 0
      messages/lt/timeTracking.json
  37. 9 2
      messages/nb/audit.json
  38. 2 1
      messages/nb/dashboard.json
  39. 2 0
      messages/nb/navigation.json
  40. 1 0
      messages/nb/permissions.json
  41. 21 2
      messages/nb/reports.json
  42. 141 0
      messages/nb/timeTracking.json
  43. 9 2
      messages/nl/audit.json
  44. 2 1
      messages/nl/dashboard.json
  45. 2 0
      messages/nl/navigation.json
  46. 1 0
      messages/nl/permissions.json
  47. 21 2
      messages/nl/reports.json
  48. 141 0
      messages/nl/timeTracking.json
  49. 9 2
      messages/pl/audit.json
  50. 2 1
      messages/pl/dashboard.json
  51. 2 0
      messages/pl/navigation.json
  52. 1 0
      messages/pl/permissions.json
  53. 21 2
      messages/pl/reports.json
  54. 141 0
      messages/pl/timeTracking.json
  55. 9 2
      messages/pt-BR/audit.json
  56. 2 1
      messages/pt-BR/dashboard.json
  57. 2 0
      messages/pt-BR/navigation.json
  58. 1 0
      messages/pt-BR/permissions.json
  59. 21 2
      messages/pt-BR/reports.json
  60. 141 0
      messages/pt-BR/timeTracking.json
  61. 9 2
      messages/ru/audit.json
  62. 2 1
      messages/ru/dashboard.json
  63. 2 0
      messages/ru/navigation.json
  64. 1 0
      messages/ru/permissions.json
  65. 21 2
      messages/ru/reports.json
  66. 141 0
      messages/ru/timeTracking.json
  67. 9 2
      messages/tr/audit.json
  68. 2 1
      messages/tr/dashboard.json
  69. 2 0
      messages/tr/navigation.json
  70. 1 0
      messages/tr/permissions.json
  71. 21 2
      messages/tr/reports.json
  72. 141 0
      messages/tr/timeTracking.json
  73. 179 0
      src/__tests__/features/time-tracking/timesheet.test.ts
  74. 3 0
      src/__tests__/i18n/audit-summaries.test.ts
  75. 33 21
      src/app/(authenticated)/layout.tsx
  76. 22 0
      src/app/(authenticated)/page.tsx
  77. 315 88
      src/app/(authenticated)/reports/reports-client.tsx
  78. 55 0
      src/app/(authenticated)/timesheets/page.tsx
  79. 1070 0
      src/app/(authenticated)/timesheets/timesheets-client.tsx
  80. 11 1
      src/components/app-sidebar.tsx
  81. 2 0
      src/components/mobile-bottom-nav.tsx
  82. 3 0
      src/components/page-header.tsx
  83. 94 0
      src/features/reports/Actions/reportActions.ts
  84. 66 2
      src/features/reports/Components/TechnicianCharts.tsx
  85. 27 0
      src/features/reports/Components/csv-export.ts
  86. 22 0
      src/features/reports/Schema/reportTypes.ts
  87. 141 0
      src/features/time-tracking/Actions/timeClockActions.ts
  88. 317 0
      src/features/time-tracking/Actions/timesheetActions.ts
  89. 337 0
      src/features/time-tracking/Components/JobClockSection.tsx
  90. 71 0
      src/features/time-tracking/Components/RunningClockPill.tsx
  91. 41 0
      src/features/time-tracking/Components/SourceBadge.tsx
  92. 189 0
      src/features/time-tracking/Components/TimeClockProvider.tsx
  93. 361 0
      src/features/time-tracking/Components/TimeEntryDialog.tsx
  94. 18 0
      src/features/time-tracking/Lib/canEdit.ts
  95. 68 0
      src/features/time-tracking/Lib/serialize.ts
  96. 290 25
      src/features/time-tracking/Lib/timeEntries.ts
  97. 56 0
      src/features/time-tracking/Lib/timesheet-csv.ts
  98. 242 0
      src/features/time-tracking/Lib/timesheet.ts
  99. 21 0
      src/features/time-tracking/hooks/useTick.ts
  100. 82 5
      src/features/vehicles/Components/MyActiveJobs.tsx

+ 9 - 2
messages/de/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
     "inspection_reminders_send": "HU-Erinnerungen gesendet",
-    "service_videoCall_send": "Link zum Videoanruf gesendet"
+    "service_videoCall_send": "Link zum Videoanruf gesendet",
+    "timeEntry_create": "Zeiteintrag hinzugefügt",
+    "timeEntry_update": "Zeiteintrag korrigiert",
+    "timeEntry_delete": "Zeiteintrag gelöscht"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Verwendung des Dokumentdesigns {name} geändert",
     "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
     "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet",
-    "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet"
+    "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet",
+    "timeEntry_create": "{minutes} Minuten für {technician} auf \"{job}\" hinzugefügt",
+    "timeEntry_update": "Zeiteintrag für {technician} auf \"{job}\" auf {minutes} Minuten korrigiert",
+    "timeEntry_stop": "Uhr für {technician} auf \"{job}\" bei {minutes} Minuten gestoppt",
+    "timeEntry_delete": "Zeiteintrag {id} gelöscht"
   }
 }

+ 2 - 1
messages/de/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "{qty} zum Auftrag hinzufügen",
     "statusReport": "Statusbericht",
     "report": "Bericht",
-    "observation": "Beobachtung"
+    "observation": "Beobachtung",
+    "empty": "Dir sind derzeit keine Aufträge zugewiesen."
   },
   "recentActivity": "Letzte Aktivitäten",
   "noRecentActivity": "Keine aktuellen Aktivitäten",

+ 2 - 0
messages/de/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Alle Teile",
     "laborPresets": "Arbeitsvorlagen",
     "reports": "Berichte",
+    "timesheets": "Stundenzettel",
     "workBoard": "Auftragsboard",
     "presenter": "Präsentation",
     "adminOverview": "Admin-Übersicht",
@@ -102,6 +103,7 @@
     "inventory": "Lagerbestand",
     "laborPresets": "Arbeitsvorlagen",
     "reports": "Berichte",
+    "timesheets": "Stundenzettel",
     "settings": "Einstellungen",
     "superAdmin": "Super Admin",
     "adminPanel": "Admin-Panel",

+ 1 - 0
messages/de/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Prüfungen",
     "tire_hotel": "Reifenhotel",
     "reports": "Berichte",
+    "time_tracking": "Zeiterfassung",
     "work_board": "Auftragstafel",
     "ai_assistant": "KI-Assistent",
     "settings": "Einstellungen"

+ 21 - 2
messages/de/reports.json

@@ -12,7 +12,9 @@
     "parts": "Teile",
     "jobAnalytics": "Auftragsanalyse",
     "retention": "Kundenbindung",
-    "vehicles": "Fahrzeuge"
+    "vehicles": "Fahrzeuge",
+    "techniciansOverview": "Übersicht",
+    "clockedTime": "Erfasste Zeit"
   },
   "dateRange": {
     "to": "bis",
@@ -138,6 +140,19 @@
       "avgRevenue": "Ø Umsatz",
       "totalHours": "Gesamtstunden",
       "avgHours": "Ø Stunden"
+    },
+    "time": {
+      "clockedHours": "Erfasste Stunden",
+      "billedHours": "Abgerechnete Stunden",
+      "efficiency": "Effizienz",
+      "techniciansClocked": "Techniker mit Zeit",
+      "chartTitle": "Erfasst vs. abgerechnet je Techniker",
+      "tableTitle": "Erfasst gegen abgerechnet",
+      "clocked": "Erfasst",
+      "billed": "Abgerechnet",
+      "hint": "Erfasste Stunden stammen von der Stempeluhr. Abgerechnete Stunden sind die Arbeitspositionen der dem Techniker zugewiesenen Aufträge, die im Zeitraum begonnen haben. Effizienz ist abgerechnet geteilt durch erfasst.",
+      "noClock": "Keine Zeit",
+      "jobsClocked": "Erfasste Aufträge"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Typ",
     "status": "Status",
     "partsUsed": "Teile",
-    "laborHours": "Arbeitsstunden"
+    "laborHours": "Arbeitsstunden",
+    "clockedHours": "Erfasste Stunden",
+    "billedHours": "Abgerechnete Stunden",
+    "efficiency": "Effizienz",
+    "jobsClocked": "Erfasste Aufträge"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/de/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Uhr läuft für {job}",
+    "stop": "Uhr stoppen",
+    "stopped": "{minutes} erfasst",
+    "switched": "Vorherigen Auftrag bei {minutes} gestoppt",
+    "startFailed": "Uhr konnte nicht gestartet werden",
+    "stopFailed": "Uhr konnte nicht gestoppt werden"
+  },
+  "job": {
+    "title": "Erfasste Zeit",
+    "running": "läuft",
+    "clockIn": "Einstempeln",
+    "clockOut": "Ausstempeln",
+    "switchHere": "Zu diesem Auftrag wechseln",
+    "switchHint": "Stoppt die Uhr bei {job} und startet sie hier",
+    "addAsLabor": "{hours, number} h als Arbeit hinzufügen",
+    "addAsLaborHint": "Fügt eine Arbeitsposition mit {hours, number} Stunden zum Standardsatz hinzu. Die erfasste Zeit selbst bleibt unverändert.",
+    "laborDescription": "Arbeit (erfasste Zeit)",
+    "empty": "Für diesen Auftrag wurde noch keine Zeit erfasst. Techniker stempeln in der App oder unter „Meine aktiven Aufträge“ auf dem Dashboard ein.",
+    "showAll": "{count} weitere anzeigen",
+    "showFewer": "Weniger anzeigen",
+    "openTimesheets": "Stundenzettel öffnen"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manuell",
+    "editedBy": "Angepasst von {name}"
+  },
+  "page": {
+    "title": "Stundenzettel",
+    "description": "Wer wann gearbeitet hat, nach der Uhr der Werkstatt.",
+    "loadFailed": "Stundenzettel konnte nicht geladen werden",
+    "presets": {
+      "today": "Heute",
+      "yesterday": "Gestern",
+      "thisWeek": "Diese Woche",
+      "lastWeek": "Letzte Woche",
+      "thisMonth": "Dieser Monat",
+      "lastMonth": "Letzter Monat"
+    },
+    "customRange": "Eigener Zeitraum",
+    "apply": "Anwenden",
+    "allTechnicians": "Alle Techniker",
+    "export": "CSV exportieren",
+    "addEntry": "Eintrag hinzufügen",
+    "stats": {
+      "total": "Gesamtzeit",
+      "totalHint": "{days, plural, =1 {1 Tag} other {# Tage}} im Zeitraum",
+      "technicians": "Techniker mit Zeit",
+      "techniciansHint": "von {count} im Team",
+      "running": "Gerade eingestempelt",
+      "runningHint": "live",
+      "average": "Durchschnitt pro aktivem Tag",
+      "averageHint": "über {days, plural, =1 {1 Tag} other {# Tage}} mit Zeit"
+    },
+    "nowStrip": {
+      "title": "Gerade eingestempelt",
+      "since": "seit {time}",
+      "stop": "Stoppen"
+    },
+    "grid": {
+      "title": "Übersicht",
+      "description": "Stunden pro Techniker und Tag. Klicken Sie auf eine Zelle, um zu diesem Tag zu springen.",
+      "technician": "Techniker",
+      "total": "Gesamt",
+      "tooLong": "Die Übersicht zeigt bis zu 31 Tage. Grenzen Sie den Zeitraum ein, um sie zu sehen."
+    },
+    "table": {
+      "time": "Zeit",
+      "duration": "Dauer",
+      "job": "Auftrag",
+      "vehicle": "Fahrzeug",
+      "source": "Quelle",
+      "note": "Notiz",
+      "running": "Läuft",
+      "dayTotal": "Tagessumme",
+      "counterSale": "Thekenverkauf",
+      "edit": "Bearbeiten",
+      "stop": "Uhr stoppen",
+      "delete": "Löschen",
+      "actions": "Aktionen"
+    },
+    "empty": {
+      "title": "Keine Zeit in diesem Zeitraum erfasst",
+      "body": "Techniker stempeln in der Torqvoice Technicians App oder unter „Meine aktiven Aufträge“ auf dem Dashboard ein. Einträge erscheinen hier, sobald eine Uhr startet.",
+      "noTechnicians": "Noch niemand im Team ist als Techniker eingerichtet. Verknüpfen Sie Teammitglieder unter Einstellungen, Team mit Technikern.",
+      "teamLink": "Team-Einstellungen öffnen"
+    },
+    "deleteTitle": "Diesen Eintrag löschen?",
+    "deleteBody": "{duration} für {job} von {technician} wird aus dem Stundenzettel entfernt. Das kann nicht rückgängig gemacht werden.",
+    "deleteConfirm": "Löschen",
+    "deleted": "Eintrag gelöscht",
+    "stoppedByManager": "Uhr bei {minutes} gestoppt",
+    "csv": {
+      "technician": "Techniker",
+      "date": "Datum",
+      "start": "Beginn",
+      "end": "Ende",
+      "duration": "Dauer",
+      "hours": "Stunden",
+      "job": "Auftrag",
+      "vehicle": "Fahrzeug",
+      "plate": "Kennzeichen",
+      "source": "Quelle",
+      "editedBy": "Angepasst von",
+      "note": "Notiz"
+    },
+    "unlinked": "Kein Konto",
+    "unlinkedHint": "Nur auf der Plantafel, nicht mit einem Teammitglied verknüpft"
+  },
+  "dialog": {
+    "addTitle": "Zeiteintrag hinzufügen",
+    "addDescription": "Zeit, die ein Techniker gearbeitet, aber nicht erfasst hat. Sie wird als manuell eingetragen markiert.",
+    "editTitle": "Zeiteintrag korrigieren",
+    "editDescription": "Techniker und Auftrag bleiben unverändert. Wer die Änderung vorgenommen hat, wird protokolliert.",
+    "technician": "Techniker",
+    "pickTechnician": "Techniker wählen",
+    "job": "Auftrag",
+    "pickJob": "Auftrag wählen",
+    "searchJobs": "Nach Titel oder Kennzeichen suchen",
+    "searching": "Suche…",
+    "noJobs": "Keine Aufträge gefunden",
+    "start": "Beginn",
+    "end": "Ende",
+    "stillRunning": "Läuft noch",
+    "leaveEndEmpty": "Lassen Sie das Ende leer, damit die Uhr weiterläuft.",
+    "endBeforeStart": "Das Ende muss nach dem Beginn liegen.",
+    "duration": "Dauer: {duration}",
+    "timesInWorkshopZone": "Zeiten gelten nach der Uhr der Werkstatt ({zone}).",
+    "note": "Notiz",
+    "notePlaceholder": "Optional. Warum das hinzugefügt oder geändert wurde.",
+    "cancel": "Abbrechen",
+    "save": "Speichern",
+    "add": "Hinzufügen",
+    "added": "Eintrag hinzugefügt",
+    "updated": "Eintrag aktualisiert",
+    "saveFailed": "Eintrag konnte nicht gespeichert werden"
+  }
+}

+ 9 - 2
messages/en/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Locked Older Invoices",
     "inspection_reminders_sent": "Sent inspection reminders",
     "inspection_reminders_send": "Sent inspection reminders",
-    "service_videoCall_send": "Sent video call link"
+    "service_videoCall_send": "Sent video call link",
+    "timeEntry_create": "Added time entry",
+    "timeEntry_update": "Corrected time entry",
+    "timeEntry_delete": "Deleted time entry"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Changed when the document design {name} is used",
     "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
     "inspection_reminders_sent": "Sent {count} inspection reminders",
-    "service_videoCall_send": "Sent the video call link via {channels}"
+    "service_videoCall_send": "Sent the video call link via {channels}",
+    "timeEntry_create": "Added {minutes} minutes for {technician} on \"{job}\"",
+    "timeEntry_update": "Corrected time entry for {technician} on \"{job}\" to {minutes} minutes",
+    "timeEntry_stop": "Stopped the clock for {technician} on \"{job}\" at {minutes} minutes",
+    "timeEntry_delete": "Deleted time entry {id}"
   }
 }

+ 2 - 1
messages/en/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Add {qty} to job",
     "statusReport": "Status Report",
     "report": "Report",
-    "observation": "Observation"
+    "observation": "Observation",
+    "empty": "No work orders are assigned to you right now."
   },
   "recentActivity": "Recent Activity",
   "noRecentActivity": "No recent activity",

+ 2 - 0
messages/en/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "All Parts",
     "laborPresets": "Labor Presets",
     "reports": "Reports",
+    "timesheets": "Timesheets",
     "workBoard": "Work Board",
     "presenter": "Presenter",
     "adminOverview": "Admin Overview",
@@ -102,6 +103,7 @@
     "inventory": "Inventory",
     "laborPresets": "Labor Presets",
     "reports": "Reports",
+    "timesheets": "Timesheets",
     "settings": "Settings",
     "superAdmin": "Super Admin",
     "adminPanel": "Admin Panel",

+ 1 - 0
messages/en/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Inspections",
     "tire_hotel": "Tire hotel",
     "reports": "Reports",
+    "time_tracking": "Time tracking",
     "work_board": "Work board",
     "ai_assistant": "AI assistant",
     "settings": "Settings"

+ 21 - 2
messages/en/reports.json

@@ -12,7 +12,9 @@
     "parts": "Parts",
     "jobAnalytics": "Job Analytics",
     "retention": "Retention",
-    "vehicles": "Vehicles"
+    "vehicles": "Vehicles",
+    "techniciansOverview": "Overview",
+    "clockedTime": "Clocked time"
   },
   "dateRange": {
     "to": "to",
@@ -138,6 +140,19 @@
       "avgRevenue": "Avg Revenue",
       "totalHours": "Total Hours",
       "avgHours": "Avg Hours"
+    },
+    "time": {
+      "clockedHours": "Clocked hours",
+      "billedHours": "Billed hours",
+      "efficiency": "Efficiency",
+      "techniciansClocked": "Technicians clocked",
+      "chartTitle": "Clocked vs billed by technician",
+      "tableTitle": "Clocked against billed",
+      "clocked": "Clocked",
+      "billed": "Billed",
+      "hint": "Clocked hours come from the time clock. Billed hours are the labor lines on jobs assigned to the technician that started in the range. Efficiency is billed divided by clocked.",
+      "noClock": "No clock",
+      "jobsClocked": "Jobs clocked"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Type",
     "status": "Status",
     "partsUsed": "Parts",
-    "laborHours": "Labor Hours"
+    "laborHours": "Labor Hours",
+    "clockedHours": "Clocked hours",
+    "billedHours": "Billed hours",
+    "efficiency": "Efficiency",
+    "jobsClocked": "Jobs clocked"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/en/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Clock running on {job}",
+    "stop": "Stop the clock",
+    "stopped": "Clocked {minutes}",
+    "switched": "Stopped the previous job at {minutes}",
+    "startFailed": "Could not start the clock",
+    "stopFailed": "Could not stop the clock"
+  },
+  "job": {
+    "title": "Clocked time",
+    "running": "running",
+    "clockIn": "Clock in",
+    "clockOut": "Clock out",
+    "switchHere": "Switch to this job",
+    "switchHint": "Stops the clock on {job} and starts it here",
+    "addAsLabor": "Add {hours, number} h as labor",
+    "addAsLaborHint": "Adds a labor line of {hours, number} hours at the default rate. The clocked time itself is not changed.",
+    "laborDescription": "Labor (clocked time)",
+    "empty": "Nothing clocked on this job yet. Technicians clock in from the app or from My Active Jobs on the dashboard.",
+    "showAll": "Show {count} more",
+    "showFewer": "Show fewer",
+    "openTimesheets": "Open timesheets"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manual",
+    "editedBy": "Adjusted by {name}"
+  },
+  "page": {
+    "title": "Timesheets",
+    "description": "Who worked when, on the workshop's clock.",
+    "loadFailed": "Could not load the timesheet",
+    "presets": {
+      "today": "Today",
+      "yesterday": "Yesterday",
+      "thisWeek": "This week",
+      "lastWeek": "Last week",
+      "thisMonth": "This month",
+      "lastMonth": "Last month"
+    },
+    "customRange": "Custom range",
+    "apply": "Apply",
+    "allTechnicians": "All technicians",
+    "export": "Export CSV",
+    "addEntry": "Add entry",
+    "stats": {
+      "total": "Total time",
+      "totalHint": "{days, plural, =1 {1 day} other {# days}} in range",
+      "technicians": "Technicians with time",
+      "techniciansHint": "of {count} on the roster",
+      "running": "On the clock now",
+      "runningHint": "live",
+      "average": "Average per active day",
+      "averageHint": "across {days, plural, =1 {1 day} other {# days}} with time"
+    },
+    "nowStrip": {
+      "title": "On the clock now",
+      "since": "since {time}",
+      "stop": "Stop"
+    },
+    "grid": {
+      "title": "Overview",
+      "description": "Hours per technician per day. Click a cell to jump to that day.",
+      "technician": "Technician",
+      "total": "Total",
+      "tooLong": "The overview grid shows up to 31 days. Narrow the range to see it."
+    },
+    "table": {
+      "time": "Time",
+      "duration": "Duration",
+      "job": "Job",
+      "vehicle": "Vehicle",
+      "source": "Source",
+      "note": "Note",
+      "running": "Running",
+      "dayTotal": "Day total",
+      "counterSale": "Counter sale",
+      "edit": "Edit",
+      "stop": "Stop clock",
+      "delete": "Delete",
+      "actions": "Actions"
+    },
+    "empty": {
+      "title": "No time clocked in this range",
+      "body": "Technicians clock in from the Torqvoice Technicians app or from My Active Jobs on the dashboard. Entries show up here the moment a clock starts.",
+      "noTechnicians": "Nobody on the team is set up as a technician yet. Link team members to technicians under Settings, Team.",
+      "teamLink": "Open team settings"
+    },
+    "deleteTitle": "Delete this entry?",
+    "deleteBody": "{duration} on {job} by {technician} will be removed from the timesheet. This cannot be undone.",
+    "deleteConfirm": "Delete",
+    "deleted": "Entry deleted",
+    "stoppedByManager": "Clock stopped at {minutes}",
+    "csv": {
+      "technician": "Technician",
+      "date": "Date",
+      "start": "Start",
+      "end": "End",
+      "duration": "Duration",
+      "hours": "Hours",
+      "job": "Job",
+      "vehicle": "Vehicle",
+      "plate": "Plate",
+      "source": "Source",
+      "editedBy": "Adjusted by",
+      "note": "Note"
+    },
+    "unlinked": "No account",
+    "unlinkedHint": "Board-only technician, not linked to a team member"
+  },
+  "dialog": {
+    "addTitle": "Add time entry",
+    "addDescription": "Time a technician worked but did not clock. It is marked as entered by hand.",
+    "editTitle": "Correct time entry",
+    "editDescription": "The entry keeps its technician and job. Who changed it is recorded.",
+    "technician": "Technician",
+    "pickTechnician": "Pick a technician",
+    "job": "Work order",
+    "pickJob": "Pick a work order",
+    "searchJobs": "Search by title or plate",
+    "searching": "Searching…",
+    "noJobs": "No work orders found",
+    "start": "Start",
+    "end": "End",
+    "stillRunning": "Still running",
+    "leaveEndEmpty": "Leave the end empty to keep the clock running.",
+    "endBeforeStart": "The end must come after the start.",
+    "duration": "Duration: {duration}",
+    "timesInWorkshopZone": "Times are on the workshop's clock ({zone}).",
+    "note": "Note",
+    "notePlaceholder": "Optional. Why this was added or changed.",
+    "cancel": "Cancel",
+    "save": "Save",
+    "add": "Add",
+    "added": "Entry added",
+    "updated": "Entry updated",
+    "saveFailed": "Could not save the entry"
+  }
+}

+ 9 - 2
messages/es/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Facturas anteriores bloqueadas",
     "inspection_reminders_sent": "Envió recordatorios de inspección",
     "inspection_reminders_send": "Envió recordatorios de inspección",
-    "service_videoCall_send": "Enlace de videollamada enviado"
+    "service_videoCall_send": "Enlace de videollamada enviado",
+    "timeEntry_create": "Entrada de tiempo añadida",
+    "timeEntry_update": "Entrada de tiempo corregida",
+    "timeEntry_delete": "Entrada de tiempo eliminada"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Cambió cuándo se usa el diseño de documento {name}",
     "settings_freezeInvoices": "Bloqueó {count, plural, one {# factura} other {# facturas}} anteriores al bloqueo con el diseño actual",
     "inspection_reminders_sent": "Envió {count} recordatorios de inspección",
-    "service_videoCall_send": "Envió el enlace de la videollamada por {channels}"
+    "service_videoCall_send": "Envió el enlace de la videollamada por {channels}",
+    "timeEntry_create": "Se añadieron {minutes} minutos para {technician} en \"{job}\"",
+    "timeEntry_update": "Entrada de tiempo de {technician} en \"{job}\" corregida a {minutes} minutos",
+    "timeEntry_stop": "Reloj de {technician} en \"{job}\" detenido en {minutes} minutos",
+    "timeEntry_delete": "Entrada de tiempo {id} eliminada"
   }
 }

+ 2 - 1
messages/es/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Añadir {qty} al trabajo",
     "statusReport": "Informe de estado",
     "report": "Informe",
-    "observation": "Observación"
+    "observation": "Observación",
+    "empty": "Ahora mismo no tienes órdenes de trabajo asignadas."
   },
   "recentActivity": "Actividad reciente",
   "noRecentActivity": "Sin actividad reciente",

+ 2 - 0
messages/es/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Todas las piezas",
     "laborPresets": "Plantillas de mano de obra",
     "reports": "Informes",
+    "timesheets": "Hojas de horas",
     "workBoard": "Tablero de trabajo",
     "presenter": "Presentador",
     "adminOverview": "Resumen de administración",
@@ -102,6 +103,7 @@
     "inventory": "Inventario",
     "laborPresets": "Plantillas de mano de obra",
     "reports": "Informes",
+    "timesheets": "Hojas de horas",
     "settings": "Configuración",
     "superAdmin": "Super Admin",
     "adminPanel": "Panel de administración",

+ 1 - 0
messages/es/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Inspecciones",
     "tire_hotel": "Hotel de neumáticos",
     "reports": "Informes",
+    "time_tracking": "Control de tiempo",
     "work_board": "Tablero de trabajo",
     "ai_assistant": "Asistente de IA",
     "settings": "Ajustes"

+ 21 - 2
messages/es/reports.json

@@ -12,7 +12,9 @@
     "parts": "Piezas",
     "jobAnalytics": "Análisis de Trabajos",
     "retention": "Retención",
-    "vehicles": "Vehículos"
+    "vehicles": "Vehículos",
+    "techniciansOverview": "Resumen",
+    "clockedTime": "Tiempo registrado"
   },
   "dateRange": {
     "to": "a",
@@ -138,6 +140,19 @@
       "avgRevenue": "Ingreso Prom.",
       "totalHours": "Horas Totales",
       "avgHours": "Horas Prom."
+    },
+    "time": {
+      "clockedHours": "Horas registradas",
+      "billedHours": "Horas facturadas",
+      "efficiency": "Eficiencia",
+      "techniciansClocked": "Técnicos con registro",
+      "chartTitle": "Registrado vs. facturado por técnico",
+      "tableTitle": "Registrado frente a facturado",
+      "clocked": "Registrado",
+      "billed": "Facturado",
+      "hint": "Las horas registradas vienen del reloj. Las horas facturadas son las líneas de mano de obra de los trabajos asignados al técnico que empezaron en el rango. La eficiencia es facturado entre registrado.",
+      "noClock": "Sin registro",
+      "jobsClocked": "Trabajos registrados"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Tipo",
     "status": "Estado",
     "partsUsed": "Piezas",
-    "laborHours": "Horas de Mano de Obra"
+    "laborHours": "Horas de Mano de Obra",
+    "clockedHours": "Horas registradas",
+    "billedHours": "Horas facturadas",
+    "efficiency": "Eficiencia",
+    "jobsClocked": "Trabajos registrados"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/es/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Reloj en marcha en {job}",
+    "stop": "Detener el reloj",
+    "stopped": "{minutes} registrados",
+    "switched": "Trabajo anterior detenido en {minutes}",
+    "startFailed": "No se pudo iniciar el reloj",
+    "stopFailed": "No se pudo detener el reloj"
+  },
+  "job": {
+    "title": "Tiempo registrado",
+    "running": "en marcha",
+    "clockIn": "Fichar entrada",
+    "clockOut": "Fichar salida",
+    "switchHere": "Cambiar a este trabajo",
+    "switchHint": "Detiene el reloj en {job} y lo inicia aquí",
+    "addAsLabor": "Añadir {hours, number} h como mano de obra",
+    "addAsLaborHint": "Añade una línea de mano de obra de {hours, number} horas a la tarifa predeterminada. El tiempo registrado no cambia.",
+    "laborDescription": "Mano de obra (tiempo registrado)",
+    "empty": "Aún no hay tiempo registrado en este trabajo. Los técnicos fichan desde la app o desde Mis trabajos activos en el panel.",
+    "showAll": "Mostrar {count} más",
+    "showFewer": "Mostrar menos",
+    "openTimesheets": "Abrir hojas de horas"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manual",
+    "editedBy": "Ajustado por {name}"
+  },
+  "page": {
+    "title": "Hojas de horas",
+    "description": "Quién trabajó y cuándo, según el reloj del taller.",
+    "loadFailed": "No se pudo cargar la hoja de horas",
+    "presets": {
+      "today": "Hoy",
+      "yesterday": "Ayer",
+      "thisWeek": "Esta semana",
+      "lastWeek": "Semana pasada",
+      "thisMonth": "Este mes",
+      "lastMonth": "Mes pasado"
+    },
+    "customRange": "Rango personalizado",
+    "apply": "Aplicar",
+    "allTechnicians": "Todos los técnicos",
+    "export": "Exportar CSV",
+    "addEntry": "Añadir entrada",
+    "stats": {
+      "total": "Tiempo total",
+      "totalHint": "{days, plural, =1 {1 día} other {# días}} en el rango",
+      "technicians": "Técnicos con tiempo",
+      "techniciansHint": "de {count} en el equipo",
+      "running": "Fichados ahora",
+      "runningHint": "en directo",
+      "average": "Promedio por día activo",
+      "averageHint": "en {days, plural, =1 {1 día} other {# días}} con tiempo"
+    },
+    "nowStrip": {
+      "title": "Fichados ahora",
+      "since": "desde {time}",
+      "stop": "Detener"
+    },
+    "grid": {
+      "title": "Resumen",
+      "description": "Horas por técnico y día. Haz clic en una celda para ir a ese día.",
+      "technician": "Técnico",
+      "total": "Total",
+      "tooLong": "El resumen muestra hasta 31 días. Reduce el rango para verlo."
+    },
+    "table": {
+      "time": "Hora",
+      "duration": "Duración",
+      "job": "Trabajo",
+      "vehicle": "Vehículo",
+      "source": "Origen",
+      "note": "Nota",
+      "running": "En marcha",
+      "dayTotal": "Total del día",
+      "counterSale": "Venta de mostrador",
+      "edit": "Editar",
+      "stop": "Detener el reloj",
+      "delete": "Eliminar",
+      "actions": "Acciones"
+    },
+    "empty": {
+      "title": "No hay tiempo registrado en este rango",
+      "body": "Los técnicos fichan desde la app Torqvoice Technicians o desde Mis trabajos activos en el panel. Las entradas aparecen aquí en cuanto arranca un reloj.",
+      "noTechnicians": "Nadie del equipo está configurado como técnico todavía. Vincula miembros del equipo con técnicos en Ajustes, Equipo.",
+      "teamLink": "Abrir ajustes del equipo"
+    },
+    "deleteTitle": "¿Eliminar esta entrada?",
+    "deleteBody": "Se eliminarán {duration} en {job} de {technician} de la hoja de horas. Esta acción no se puede deshacer.",
+    "deleteConfirm": "Eliminar",
+    "deleted": "Entrada eliminada",
+    "stoppedByManager": "Reloj detenido en {minutes}",
+    "csv": {
+      "technician": "Técnico",
+      "date": "Fecha",
+      "start": "Inicio",
+      "end": "Fin",
+      "duration": "Duración",
+      "hours": "Horas",
+      "job": "Trabajo",
+      "vehicle": "Vehículo",
+      "plate": "Matrícula",
+      "source": "Origen",
+      "editedBy": "Ajustado por",
+      "note": "Nota"
+    },
+    "unlinked": "Sin cuenta",
+    "unlinkedHint": "Técnico solo del tablero, no vinculado a un miembro del equipo"
+  },
+  "dialog": {
+    "addTitle": "Añadir entrada de tiempo",
+    "addDescription": "Tiempo que un técnico trabajó pero no fichó. Se marca como introducido a mano.",
+    "editTitle": "Corregir entrada de tiempo",
+    "editDescription": "La entrada conserva su técnico y trabajo. Se registra quién la cambió.",
+    "technician": "Técnico",
+    "pickTechnician": "Elige un técnico",
+    "job": "Orden de trabajo",
+    "pickJob": "Elige una orden de trabajo",
+    "searchJobs": "Buscar por título o matrícula",
+    "searching": "Buscando…",
+    "noJobs": "No se encontraron órdenes de trabajo",
+    "start": "Inicio",
+    "end": "Fin",
+    "stillRunning": "Todavía en marcha",
+    "leaveEndEmpty": "Deja el fin vacío para mantener el reloj en marcha.",
+    "endBeforeStart": "El fin debe ser posterior al inicio.",
+    "duration": "Duración: {duration}",
+    "timesInWorkshopZone": "Las horas siguen el reloj del taller ({zone}).",
+    "note": "Nota",
+    "notePlaceholder": "Opcional. Por qué se añadió o cambió.",
+    "cancel": "Cancelar",
+    "save": "Guardar",
+    "add": "Añadir",
+    "added": "Entrada añadida",
+    "updated": "Entrada actualizada",
+    "saveFailed": "No se pudo guardar la entrada"
+  }
+}

+ 9 - 2
messages/fr/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Anciennes factures verrouillées",
     "inspection_reminders_sent": "Rappels de contrôle envoyés",
     "inspection_reminders_send": "Rappels de contrôle envoyés",
-    "service_videoCall_send": "Lien d'appel vidéo envoyé"
+    "service_videoCall_send": "Lien d'appel vidéo envoyé",
+    "timeEntry_create": "Entrée de temps ajoutée",
+    "timeEntry_update": "Entrée de temps corrigée",
+    "timeEntry_delete": "Entrée de temps supprimée"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "A modifié quand le design de document {name} est utilisé",
     "settings_freezeInvoices": "A verrouillé {count, plural, one {# facture} other {# factures}} antérieures au verrouillage avec le design actuel",
     "inspection_reminders_sent": "{count} rappels de contrôle envoyés",
-    "service_videoCall_send": "A envoyé le lien de l'appel vidéo par {channels}"
+    "service_videoCall_send": "A envoyé le lien de l'appel vidéo par {channels}",
+    "timeEntry_create": "{minutes} minutes ajoutées pour {technician} sur « {job} »",
+    "timeEntry_update": "Entrée de temps de {technician} sur « {job} » corrigée à {minutes} minutes",
+    "timeEntry_stop": "Pointage de {technician} sur « {job} » arrêté à {minutes} minutes",
+    "timeEntry_delete": "Entrée de temps {id} supprimée"
   }
 }

+ 2 - 1
messages/fr/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Ajouter {qty} au travail",
     "statusReport": "Rapport d'état",
     "report": "Rapport",
-    "observation": "Observation"
+    "observation": "Observation",
+    "empty": "Aucun ordre de travail ne vous est attribué pour le moment."
   },
   "recentActivity": "Activité récente",
   "noRecentActivity": "Aucune activité récente",

+ 2 - 0
messages/fr/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Toutes les pièces",
     "laborPresets": "Modèles de main-d'œuvre",
     "reports": "Rapports",
+    "timesheets": "Feuilles de temps",
     "workBoard": "Tableau de travail",
     "presenter": "Présentateur",
     "adminOverview": "Vue d’ensemble admin",
@@ -102,6 +103,7 @@
     "inventory": "Inventaire",
     "laborPresets": "Modèles de main-d'œuvre",
     "reports": "Rapports",
+    "timesheets": "Feuilles de temps",
     "settings": "Paramètres",
     "superAdmin": "Super Admin",
     "adminPanel": "Panneau d'administration",

+ 1 - 0
messages/fr/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Contrôles",
     "tire_hotel": "Hôtel à pneus",
     "reports": "Rapports",
+    "time_tracking": "Suivi du temps",
     "work_board": "Tableau d’atelier",
     "ai_assistant": "Assistant IA",
     "settings": "Paramètres"

+ 21 - 2
messages/fr/reports.json

@@ -12,7 +12,9 @@
     "parts": "Pièces",
     "jobAnalytics": "Analyse des Travaux",
     "retention": "Fidélisation",
-    "vehicles": "Véhicules"
+    "vehicles": "Véhicules",
+    "techniciansOverview": "Vue d'ensemble",
+    "clockedTime": "Temps pointé"
   },
   "dateRange": {
     "to": "au",
@@ -138,6 +140,19 @@
       "avgRevenue": "Revenus Moy.",
       "totalHours": "Heures Totales",
       "avgHours": "Heures Moy."
+    },
+    "time": {
+      "clockedHours": "Heures pointées",
+      "billedHours": "Heures facturées",
+      "efficiency": "Efficacité",
+      "techniciansClocked": "Techniciens ayant pointé",
+      "chartTitle": "Pointé et facturé par technicien",
+      "tableTitle": "Pointé contre facturé",
+      "clocked": "Pointé",
+      "billed": "Facturé",
+      "hint": "Les heures pointées viennent de la pointeuse. Les heures facturées sont les lignes de main-d'œuvre des travaux attribués au technicien et commencés dans la période. L'efficacité est facturé divisé par pointé.",
+      "noClock": "Pas de pointage",
+      "jobsClocked": "Travaux pointés"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Type",
     "status": "Statut",
     "partsUsed": "Pièces",
-    "laborHours": "Heures de Main-d'œuvre"
+    "laborHours": "Heures de Main-d'œuvre",
+    "clockedHours": "Heures pointées",
+    "billedHours": "Heures facturées",
+    "efficiency": "Efficacité",
+    "jobsClocked": "Travaux pointés"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/fr/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Pointage en cours sur {job}",
+    "stop": "Arrêter le pointage",
+    "stopped": "{minutes} pointées",
+    "switched": "Travail précédent arrêté à {minutes}",
+    "startFailed": "Impossible de démarrer le pointage",
+    "stopFailed": "Impossible d'arrêter le pointage"
+  },
+  "job": {
+    "title": "Temps pointé",
+    "running": "en cours",
+    "clockIn": "Pointer",
+    "clockOut": "Dépointer",
+    "switchHere": "Passer à ce travail",
+    "switchHint": "Arrête le pointage sur {job} et le démarre ici",
+    "addAsLabor": "Ajouter {hours, number} h en main-d'œuvre",
+    "addAsLaborHint": "Ajoute une ligne de main-d'œuvre de {hours, number} heures au taux par défaut. Le temps pointé lui-même n'est pas modifié.",
+    "laborDescription": "Main-d'œuvre (temps pointé)",
+    "empty": "Aucun temps pointé sur ce travail pour l'instant. Les techniciens pointent depuis l'application ou depuis Mes travaux actifs sur le tableau de bord.",
+    "showAll": "Afficher {count} de plus",
+    "showFewer": "Afficher moins",
+    "openTimesheets": "Ouvrir les feuilles de temps"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manuel",
+    "editedBy": "Ajusté par {name}"
+  },
+  "page": {
+    "title": "Feuilles de temps",
+    "description": "Qui a travaillé et quand, à l'heure de l'atelier.",
+    "loadFailed": "Impossible de charger la feuille de temps",
+    "presets": {
+      "today": "Aujourd'hui",
+      "yesterday": "Hier",
+      "thisWeek": "Cette semaine",
+      "lastWeek": "Semaine dernière",
+      "thisMonth": "Ce mois-ci",
+      "lastMonth": "Mois dernier"
+    },
+    "customRange": "Période personnalisée",
+    "apply": "Appliquer",
+    "allTechnicians": "Tous les techniciens",
+    "export": "Exporter en CSV",
+    "addEntry": "Ajouter une entrée",
+    "stats": {
+      "total": "Temps total",
+      "totalHint": "{days, plural, =1 {1 jour} other {# jours}} dans la période",
+      "technicians": "Techniciens avec du temps",
+      "techniciansHint": "sur {count} dans l'équipe",
+      "running": "Pointés en ce moment",
+      "runningHint": "en direct",
+      "average": "Moyenne par jour actif",
+      "averageHint": "sur {days, plural, =1 {1 jour} other {# jours}} avec du temps"
+    },
+    "nowStrip": {
+      "title": "Pointés en ce moment",
+      "since": "depuis {time}",
+      "stop": "Arrêter"
+    },
+    "grid": {
+      "title": "Vue d'ensemble",
+      "description": "Heures par technicien et par jour. Cliquez sur une cellule pour aller à ce jour.",
+      "technician": "Technicien",
+      "total": "Total",
+      "tooLong": "La vue d'ensemble affiche jusqu'à 31 jours. Réduisez la période pour la voir."
+    },
+    "table": {
+      "time": "Heure",
+      "duration": "Durée",
+      "job": "Travail",
+      "vehicle": "Véhicule",
+      "source": "Source",
+      "note": "Note",
+      "running": "En cours",
+      "dayTotal": "Total du jour",
+      "counterSale": "Vente au comptoir",
+      "edit": "Modifier",
+      "stop": "Arrêter le pointage",
+      "delete": "Supprimer",
+      "actions": "Actions"
+    },
+    "empty": {
+      "title": "Aucun temps pointé sur cette période",
+      "body": "Les techniciens pointent depuis l'application Torqvoice Technicians ou depuis Mes travaux actifs sur le tableau de bord. Les entrées apparaissent ici dès qu'un pointage démarre.",
+      "noTechnicians": "Personne dans l'équipe n'est encore configuré comme technicien. Associez des membres de l'équipe à des techniciens dans Paramètres, Équipe.",
+      "teamLink": "Ouvrir les paramètres de l'équipe"
+    },
+    "deleteTitle": "Supprimer cette entrée ?",
+    "deleteBody": "{duration} sur {job} par {technician} seront retirées de la feuille de temps. Cette action est irréversible.",
+    "deleteConfirm": "Supprimer",
+    "deleted": "Entrée supprimée",
+    "stoppedByManager": "Pointage arrêté à {minutes}",
+    "csv": {
+      "technician": "Technicien",
+      "date": "Date",
+      "start": "Début",
+      "end": "Fin",
+      "duration": "Durée",
+      "hours": "Heures",
+      "job": "Travail",
+      "vehicle": "Véhicule",
+      "plate": "Immatriculation",
+      "source": "Source",
+      "editedBy": "Ajusté par",
+      "note": "Note"
+    },
+    "unlinked": "Sans compte",
+    "unlinkedHint": "Technicien du tableau seulement, non lié à un membre de l'équipe"
+  },
+  "dialog": {
+    "addTitle": "Ajouter une entrée de temps",
+    "addDescription": "Du temps qu'un technicien a travaillé sans pointer. Elle est marquée comme saisie à la main.",
+    "editTitle": "Corriger l'entrée de temps",
+    "editDescription": "L'entrée garde son technicien et son travail. L'auteur de la modification est enregistré.",
+    "technician": "Technicien",
+    "pickTechnician": "Choisir un technicien",
+    "job": "Ordre de travail",
+    "pickJob": "Choisir un ordre de travail",
+    "searchJobs": "Rechercher par titre ou immatriculation",
+    "searching": "Recherche…",
+    "noJobs": "Aucun ordre de travail trouvé",
+    "start": "Début",
+    "end": "Fin",
+    "stillRunning": "Toujours en cours",
+    "leaveEndEmpty": "Laissez la fin vide pour garder le pointage en cours.",
+    "endBeforeStart": "La fin doit être après le début.",
+    "duration": "Durée : {duration}",
+    "timesInWorkshopZone": "Les heures suivent l'horloge de l'atelier ({zone}).",
+    "note": "Note",
+    "notePlaceholder": "Facultatif. Pourquoi cela a été ajouté ou modifié.",
+    "cancel": "Annuler",
+    "save": "Enregistrer",
+    "add": "Ajouter",
+    "added": "Entrée ajoutée",
+    "updated": "Entrée mise à jour",
+    "saveFailed": "Impossible d'enregistrer l'entrée"
+  }
+}

+ 9 - 2
messages/it/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Fatture precedenti bloccate",
     "inspection_reminders_sent": "Promemoria revisione inviati",
     "inspection_reminders_send": "Promemoria revisione inviati",
-    "service_videoCall_send": "Link videochiamata inviato"
+    "service_videoCall_send": "Link videochiamata inviato",
+    "timeEntry_create": "Voce oraria aggiunta",
+    "timeEntry_update": "Voce oraria corretta",
+    "timeEntry_delete": "Voce oraria eliminata"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Modificato quando viene usato il design del documento {name}",
     "settings_freezeInvoices": "Ha bloccato {count, plural, one {# fattura} other {# fatture}} precedenti al blocco con il design attuale",
     "inspection_reminders_sent": "Inviati {count} promemoria revisione",
-    "service_videoCall_send": "Ha inviato il link della videochiamata via {channels}"
+    "service_videoCall_send": "Ha inviato il link della videochiamata via {channels}",
+    "timeEntry_create": "Aggiunti {minutes} minuti per {technician} su \"{job}\"",
+    "timeEntry_update": "Voce oraria di {technician} su \"{job}\" corretta a {minutes} minuti",
+    "timeEntry_stop": "Timer di {technician} su \"{job}\" fermato a {minutes} minuti",
+    "timeEntry_delete": "Voce oraria {id} eliminata"
   }
 }

+ 2 - 1
messages/it/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Aggiungi {qty} al lavoro",
     "statusReport": "Rapporto di stato",
     "report": "Rapporto",
-    "observation": "Osservazione"
+    "observation": "Osservazione",
+    "empty": "Al momento non hai ordini di lavoro assegnati."
   },
   "recentActivity": "Attività recente",
   "noRecentActivity": "Nessuna attività recente",

+ 2 - 0
messages/it/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Tutti i ricambi",
     "laborPresets": "Modelli manodopera",
     "reports": "Report",
+    "timesheets": "Fogli ore",
     "workBoard": "Bacheca lavori",
     "presenter": "Presentatore",
     "adminOverview": "Panoramica amministrazione",
@@ -102,6 +103,7 @@
     "inventory": "Inventario",
     "laborPresets": "Modelli manodopera",
     "reports": "Report",
+    "timesheets": "Fogli ore",
     "settings": "Impostazioni",
     "superAdmin": "Super Admin",
     "adminPanel": "Pannello di amministrazione",

+ 1 - 0
messages/it/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Ispezioni",
     "tire_hotel": "Hotel pneumatici",
     "reports": "Report",
+    "time_tracking": "Rilevazione tempi",
     "work_board": "Bacheca lavori",
     "ai_assistant": "Assistente IA",
     "settings": "Impostazioni"

+ 21 - 2
messages/it/reports.json

@@ -12,7 +12,9 @@
     "parts": "Ricambi",
     "jobAnalytics": "Analisi Lavori",
     "retention": "Fidelizzazione",
-    "vehicles": "Veicoli"
+    "vehicles": "Veicoli",
+    "techniciansOverview": "Panoramica",
+    "clockedTime": "Tempo registrato"
   },
   "dateRange": {
     "to": "a",
@@ -138,6 +140,19 @@
       "avgRevenue": "Ricavo Medio",
       "totalHours": "Ore Totali",
       "avgHours": "Ore Medie"
+    },
+    "time": {
+      "clockedHours": "Ore registrate",
+      "billedHours": "Ore fatturate",
+      "efficiency": "Efficienza",
+      "techniciansClocked": "Tecnici con tempo",
+      "chartTitle": "Registrato e fatturato per tecnico",
+      "tableTitle": "Registrato contro fatturato",
+      "clocked": "Registrato",
+      "billed": "Fatturato",
+      "hint": "Le ore registrate vengono dal timer. Le ore fatturate sono le righe di manodopera dei lavori assegnati al tecnico iniziati nell'intervallo. L'efficienza è fatturato diviso registrato.",
+      "noClock": "Nessun tempo",
+      "jobsClocked": "Lavori registrati"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Tipo",
     "status": "Stato",
     "partsUsed": "Ricambi",
-    "laborHours": "Ore di Manodopera"
+    "laborHours": "Ore di Manodopera",
+    "clockedHours": "Ore registrate",
+    "billedHours": "Ore fatturate",
+    "efficiency": "Efficienza",
+    "jobsClocked": "Lavori registrati"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/it/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Timer attivo su {job}",
+    "stop": "Ferma il timer",
+    "stopped": "{minutes} registrati",
+    "switched": "Lavoro precedente fermato a {minutes}",
+    "startFailed": "Impossibile avviare il timer",
+    "stopFailed": "Impossibile fermare il timer"
+  },
+  "job": {
+    "title": "Tempo registrato",
+    "running": "in corso",
+    "clockIn": "Timbra entrata",
+    "clockOut": "Timbra uscita",
+    "switchHere": "Passa a questo lavoro",
+    "switchHint": "Ferma il timer su {job} e lo avvia qui",
+    "addAsLabor": "Aggiungi {hours, number} h come manodopera",
+    "addAsLaborHint": "Aggiunge una riga di manodopera di {hours, number} ore alla tariffa predefinita. Il tempo registrato non viene modificato.",
+    "laborDescription": "Manodopera (tempo registrato)",
+    "empty": "Nessun tempo registrato su questo lavoro. I tecnici timbrano dall'app o da I miei lavori attivi nella dashboard.",
+    "showAll": "Mostra altri {count}",
+    "showFewer": "Mostra meno",
+    "openTimesheets": "Apri i fogli ore"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manuale",
+    "editedBy": "Modificato da {name}"
+  },
+  "page": {
+    "title": "Fogli ore",
+    "description": "Chi ha lavorato e quando, secondo l'orologio dell'officina.",
+    "loadFailed": "Impossibile caricare il foglio ore",
+    "presets": {
+      "today": "Oggi",
+      "yesterday": "Ieri",
+      "thisWeek": "Questa settimana",
+      "lastWeek": "Settimana scorsa",
+      "thisMonth": "Questo mese",
+      "lastMonth": "Mese scorso"
+    },
+    "customRange": "Intervallo personalizzato",
+    "apply": "Applica",
+    "allTechnicians": "Tutti i tecnici",
+    "export": "Esporta CSV",
+    "addEntry": "Aggiungi voce",
+    "stats": {
+      "total": "Tempo totale",
+      "totalHint": "{days, plural, =1 {1 giorno} other {# giorni}} nell'intervallo",
+      "technicians": "Tecnici con tempo",
+      "techniciansHint": "su {count} in squadra",
+      "running": "Timbrati ora",
+      "runningHint": "in tempo reale",
+      "average": "Media per giorno attivo",
+      "averageHint": "su {days, plural, =1 {1 giorno} other {# giorni}} con tempo"
+    },
+    "nowStrip": {
+      "title": "Timbrati ora",
+      "since": "dalle {time}",
+      "stop": "Ferma"
+    },
+    "grid": {
+      "title": "Panoramica",
+      "description": "Ore per tecnico e per giorno. Clicca una cella per andare a quel giorno.",
+      "technician": "Tecnico",
+      "total": "Totale",
+      "tooLong": "La panoramica mostra fino a 31 giorni. Restringi l'intervallo per vederla."
+    },
+    "table": {
+      "time": "Ora",
+      "duration": "Durata",
+      "job": "Lavoro",
+      "vehicle": "Veicolo",
+      "source": "Origine",
+      "note": "Nota",
+      "running": "In corso",
+      "dayTotal": "Totale giorno",
+      "counterSale": "Vendita al banco",
+      "edit": "Modifica",
+      "stop": "Ferma il timer",
+      "delete": "Elimina",
+      "actions": "Azioni"
+    },
+    "empty": {
+      "title": "Nessun tempo registrato in questo intervallo",
+      "body": "I tecnici timbrano dall'app Torqvoice Technicians o da I miei lavori attivi nella dashboard. Le voci compaiono qui appena parte un timer.",
+      "noTechnicians": "Nessuno della squadra è ancora configurato come tecnico. Collega i membri della squadra ai tecnici in Impostazioni, Squadra.",
+      "teamLink": "Apri le impostazioni della squadra"
+    },
+    "deleteTitle": "Eliminare questa voce?",
+    "deleteBody": "{duration} su {job} di {technician} verranno rimossi dal foglio ore. L'operazione non può essere annullata.",
+    "deleteConfirm": "Elimina",
+    "deleted": "Voce eliminata",
+    "stoppedByManager": "Timer fermato a {minutes}",
+    "csv": {
+      "technician": "Tecnico",
+      "date": "Data",
+      "start": "Inizio",
+      "end": "Fine",
+      "duration": "Durata",
+      "hours": "Ore",
+      "job": "Lavoro",
+      "vehicle": "Veicolo",
+      "plate": "Targa",
+      "source": "Origine",
+      "editedBy": "Modificato da",
+      "note": "Nota"
+    },
+    "unlinked": "Senza account",
+    "unlinkedHint": "Tecnico solo da bacheca, non collegato a un membro della squadra"
+  },
+  "dialog": {
+    "addTitle": "Aggiungi voce oraria",
+    "addDescription": "Tempo lavorato da un tecnico ma non timbrato. Viene contrassegnato come inserito a mano.",
+    "editTitle": "Correggi voce oraria",
+    "editDescription": "La voce mantiene tecnico e lavoro. Viene registrato chi l'ha modificata.",
+    "technician": "Tecnico",
+    "pickTechnician": "Scegli un tecnico",
+    "job": "Ordine di lavoro",
+    "pickJob": "Scegli un ordine di lavoro",
+    "searchJobs": "Cerca per titolo o targa",
+    "searching": "Ricerca…",
+    "noJobs": "Nessun ordine di lavoro trovato",
+    "start": "Inizio",
+    "end": "Fine",
+    "stillRunning": "Ancora in corso",
+    "leaveEndEmpty": "Lascia vuota la fine per mantenere il timer attivo.",
+    "endBeforeStart": "La fine deve essere dopo l'inizio.",
+    "duration": "Durata: {duration}",
+    "timesInWorkshopZone": "Gli orari seguono l'orologio dell'officina ({zone}).",
+    "note": "Nota",
+    "notePlaceholder": "Facoltativo. Perché è stato aggiunto o modificato.",
+    "cancel": "Annulla",
+    "save": "Salva",
+    "add": "Aggiungi",
+    "added": "Voce aggiunta",
+    "updated": "Voce aggiornata",
+    "saveFailed": "Impossibile salvare la voce"
+  }
+}

+ 9 - 2
messages/lt/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Užrakintos senesnės sąskaitos",
     "inspection_reminders_sent": "Išsiųsti apžiūros priminimai",
     "inspection_reminders_send": "Išsiųsti apžiūros priminimai",
-    "service_videoCall_send": "Išsiųsta vaizdo skambučio nuoroda"
+    "service_videoCall_send": "Išsiųsta vaizdo skambučio nuoroda",
+    "timeEntry_create": "Pridėtas laiko įrašas",
+    "timeEntry_update": "Pataisytas laiko įrašas",
+    "timeEntry_delete": "Ištrintas laiko įrašas"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dokumento dizainas {name}",
     "settings_freezeInvoices": "Užrakino {count, plural, one {# sąskaitą} few {# sąskaitas} other {# sąskaitų}} iki užrakinimo su dabartiniu dizainu",
     "inspection_reminders_sent": "Išsiųsta {count} apžiūros priminimų",
-    "service_videoCall_send": "Išsiuntė vaizdo skambučio nuorodą per {channels}"
+    "service_videoCall_send": "Išsiuntė vaizdo skambučio nuorodą per {channels}",
+    "timeEntry_create": "Pridėta {minutes} min. technikui {technician} darbe „{job}“",
+    "timeEntry_update": "Techniko {technician} laiko įrašas darbe „{job}“ pataisytas į {minutes} min.",
+    "timeEntry_stop": "Techniko {technician} laikmatis darbe „{job}“ sustabdytas ties {minutes} min.",
+    "timeEntry_delete": "Ištrintas laiko įrašas {id}"
   }
 }

+ 2 - 1
messages/lt/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Pridėti {qty} prie darbo",
     "statusReport": "Būsenos ataskaita",
     "report": "Ataskaita",
-    "observation": "Pastebėjimas"
+    "observation": "Pastebėjimas",
+    "empty": "Šiuo metu jums nepriskirta jokių darbo užsakymų."
   },
   "recentActivity": "Naujausios veiklos",
   "noRecentActivity": "Naujausių veiklų nėra",

+ 2 - 0
messages/lt/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Visos dalys",
     "laborPresets": "Darbų šablonai",
     "reports": "Ataskaitos",
+    "timesheets": "Darbo laiko žiniaraščiai",
     "workBoard": "Darbų lenta",
     "presenter": "Prezentacija",
     "adminOverview": "Administravimo apžvalga",
@@ -102,6 +103,7 @@
     "inventory": "Inventorius",
     "laborPresets": "Darbų šablonai",
     "reports": "Ataskaitos",
+    "timesheets": "Darbo laiko žiniaraščiai",
     "settings": "Nustatymai",
     "superAdmin": "Vyriausiasis administratorius",
     "adminPanel": "Administravimo skydelis",

+ 1 - 0
messages/lt/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Patikros",
     "tire_hotel": "Padangų viešbutis",
     "reports": "Ataskaitos",
+    "time_tracking": "Laiko apskaita",
     "work_board": "Darbų lenta",
     "ai_assistant": "DI asistentas",
     "settings": "Nustatymai"

+ 21 - 2
messages/lt/reports.json

@@ -12,7 +12,9 @@
     "parts": "Dalys",
     "jobAnalytics": "Darbų analitika",
     "retention": "Grįžtamumas",
-    "vehicles": "Transporto priemonės"
+    "vehicles": "Transporto priemonės",
+    "techniciansOverview": "Apžvalga",
+    "clockedTime": "Užregistruotas laikas"
   },
   "dateRange": {
     "to": "iki",
@@ -138,6 +140,19 @@
       "avgRevenue": "Vid. pajamos",
       "totalHours": "Iš viso valandų",
       "avgHours": "Vid. valandos"
+    },
+    "time": {
+      "clockedHours": "Užregistruotos valandos",
+      "billedHours": "Sąskaitoje nurodytos valandos",
+      "efficiency": "Efektyvumas",
+      "techniciansClocked": "Technikai su laiku",
+      "chartTitle": "Užregistruota ir apmokestinta pagal techniką",
+      "tableTitle": "Užregistruota prieš apmokestinta",
+      "clocked": "Užregistruota",
+      "billed": "Apmokestinta",
+      "hint": "Užregistruotos valandos ateina iš laikmačio. Apmokestintos valandos yra technikui priskirtų darbų, prasidėjusių laikotarpyje, darbo eilutės. Efektyvumas yra apmokestinta padalinta iš užregistruotos.",
+      "noClock": "Nėra laiko",
+      "jobsClocked": "Užregistruoti darbai"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Tipas",
     "status": "Būsena",
     "partsUsed": "Dalys",
-    "laborHours": "Darbo valandos"
+    "laborHours": "Darbo valandos",
+    "clockedHours": "Užregistruotos valandos",
+    "billedHours": "Sąskaitoje nurodytos valandos",
+    "efficiency": "Efektyvumas",
+    "jobsClocked": "Užregistruoti darbai"
   },
   "pdf": {
     "reportTitle": "Verslo ataskaita",

+ 141 - 0
messages/lt/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Laikmatis eina darbe {job}",
+    "stop": "Sustabdyti laikmatį",
+    "stopped": "Užregistruota {minutes}",
+    "switched": "Ankstesnis darbas sustabdytas ties {minutes}",
+    "startFailed": "Nepavyko paleisti laikmačio",
+    "stopFailed": "Nepavyko sustabdyti laikmačio"
+  },
+  "job": {
+    "title": "Užregistruotas laikas",
+    "running": "eina",
+    "clockIn": "Pradėti darbą",
+    "clockOut": "Baigti darbą",
+    "switchHere": "Pereiti prie šio darbo",
+    "switchHint": "Sustabdo laikmatį darbe {job} ir paleidžia jį čia",
+    "addAsLabor": "Pridėti {hours, number} val. kaip darbą",
+    "addAsLaborHint": "Prideda {hours, number} val. darbo eilutę pagal numatytąjį įkainį. Pats užregistruotas laikas nesikeičia.",
+    "laborDescription": "Darbas (užregistruotas laikas)",
+    "empty": "Šiame darbe laikas dar neužregistruotas. Technikai pradeda darbą programėlėje arba skydelio skiltyje „Mano aktyvūs darbai“.",
+    "showAll": "Rodyti dar {count}",
+    "showFewer": "Rodyti mažiau",
+    "openTimesheets": "Atidaryti žiniaraščius"
+  },
+  "source": {
+    "app": "Programėlė",
+    "web": "Žiniatinklis",
+    "manual": "Rankinis",
+    "editedBy": "Pataisė {name}"
+  },
+  "page": {
+    "title": "Darbo laiko žiniaraščiai",
+    "description": "Kas ir kada dirbo, pagal dirbtuvių laikrodį.",
+    "loadFailed": "Nepavyko įkelti žiniaraščio",
+    "presets": {
+      "today": "Šiandien",
+      "yesterday": "Vakar",
+      "thisWeek": "Ši savaitė",
+      "lastWeek": "Praėjusi savaitė",
+      "thisMonth": "Šis mėnuo",
+      "lastMonth": "Praėjęs mėnuo"
+    },
+    "customRange": "Pasirinktinis laikotarpis",
+    "apply": "Taikyti",
+    "allTechnicians": "Visi technikai",
+    "export": "Eksportuoti CSV",
+    "addEntry": "Pridėti įrašą",
+    "stats": {
+      "total": "Bendras laikas",
+      "totalHint": "laikotarpyje {days, plural, =1 {1 diena} one {# diena} few {# dienos} other {# dienų}}",
+      "technicians": "Technikai su laiku",
+      "techniciansHint": "iš {count} komandoje",
+      "running": "Dirba dabar",
+      "runningHint": "tiesiogiai",
+      "average": "Vidurkis per aktyvią dieną",
+      "averageHint": "per {days, plural, =1 {1 dieną} one {# dieną} few {# dienas} other {# dienų}} su laiku"
+    },
+    "nowStrip": {
+      "title": "Dirba dabar",
+      "since": "nuo {time}",
+      "stop": "Sustabdyti"
+    },
+    "grid": {
+      "title": "Apžvalga",
+      "description": "Valandos pagal techniką ir dieną. Spustelėkite langelį, kad pereitumėte į tą dieną.",
+      "technician": "Technikas",
+      "total": "Iš viso",
+      "tooLong": "Apžvalga rodo iki 31 dienos. Susiaurinkite laikotarpį, kad ją pamatytumėte."
+    },
+    "table": {
+      "time": "Laikas",
+      "duration": "Trukmė",
+      "job": "Darbas",
+      "vehicle": "Transporto priemonė",
+      "source": "Šaltinis",
+      "note": "Pastaba",
+      "running": "Eina",
+      "dayTotal": "Dienos suma",
+      "counterSale": "Pardavimas prie prekystalio",
+      "edit": "Redaguoti",
+      "stop": "Sustabdyti laikmatį",
+      "delete": "Ištrinti",
+      "actions": "Veiksmai"
+    },
+    "empty": {
+      "title": "Šiame laikotarpyje laikas neužregistruotas",
+      "body": "Technikai pradeda darbą programėlėje „Torqvoice Technicians“ arba skydelio skiltyje „Mano aktyvūs darbai“. Įrašai čia atsiranda vos paleidus laikmatį.",
+      "noTechnicians": "Komandoje dar niekas nenustatytas kaip technikas. Susiekite komandos narius su technikais skiltyje Nustatymai, Komanda.",
+      "teamLink": "Atidaryti komandos nustatymus"
+    },
+    "deleteTitle": "Ištrinti šį įrašą?",
+    "deleteBody": "{technician} {duration} darbe {job} bus pašalinta iš žiniaraščio. Šio veiksmo atšaukti negalima.",
+    "deleteConfirm": "Ištrinti",
+    "deleted": "Įrašas ištrintas",
+    "stoppedByManager": "Laikmatis sustabdytas ties {minutes}",
+    "csv": {
+      "technician": "Technikas",
+      "date": "Data",
+      "start": "Pradžia",
+      "end": "Pabaiga",
+      "duration": "Trukmė",
+      "hours": "Valandos",
+      "job": "Darbas",
+      "vehicle": "Transporto priemonė",
+      "plate": "Numeris",
+      "source": "Šaltinis",
+      "editedBy": "Pataisė",
+      "note": "Pastaba"
+    },
+    "unlinked": "Be paskyros",
+    "unlinkedHint": "Tik lentos technikas, nesusietas su komandos nariu"
+  },
+  "dialog": {
+    "addTitle": "Pridėti laiko įrašą",
+    "addDescription": "Laikas, kurį technikas dirbo, bet neužregistravo. Pažymimas kaip įvestas ranka.",
+    "editTitle": "Pataisyti laiko įrašą",
+    "editDescription": "Įrašo technikas ir darbas nesikeičia. Užfiksuojama, kas jį pakeitė.",
+    "technician": "Technikas",
+    "pickTechnician": "Pasirinkite techniką",
+    "job": "Darbo užsakymas",
+    "pickJob": "Pasirinkite darbo užsakymą",
+    "searchJobs": "Ieškoti pagal pavadinimą ar numerį",
+    "searching": "Ieškoma…",
+    "noJobs": "Darbo užsakymų nerasta",
+    "start": "Pradžia",
+    "end": "Pabaiga",
+    "stillRunning": "Vis dar eina",
+    "leaveEndEmpty": "Palikite pabaigą tuščią, kad laikmatis eitų toliau.",
+    "endBeforeStart": "Pabaiga turi būti po pradžios.",
+    "duration": "Trukmė: {duration}",
+    "timesInWorkshopZone": "Laikas nurodytas pagal dirbtuvių laikrodį ({zone}).",
+    "note": "Pastaba",
+    "notePlaceholder": "Neprivaloma. Kodėl tai pridėta ar pakeista.",
+    "cancel": "Atšaukti",
+    "save": "Išsaugoti",
+    "add": "Pridėti",
+    "added": "Įrašas pridėtas",
+    "updated": "Įrašas atnaujintas",
+    "saveFailed": "Nepavyko išsaugoti įrašo"
+  }
+}

+ 9 - 2
messages/nb/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Låste eldre fakturaer",
     "inspection_reminders_sent": "Sendte påminnelser om EU-kontroll",
     "inspection_reminders_send": "Sendte påminnelser om EU-kontroll",
-    "service_videoCall_send": "Sendte lenke til videosamtale"
+    "service_videoCall_send": "Sendte lenke til videosamtale",
+    "timeEntry_create": "La til tidsoppføring",
+    "timeEntry_update": "Korrigerte tidsoppføring",
+    "timeEntry_delete": "Slettet tidsoppføring"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Endret når dokumentdesignet {name} brukes",
     "settings_freezeInvoices": "Låste {count, plural, one {# faktura} other {# fakturaer}} fra før låsing med gjeldende design",
     "inspection_reminders_sent": "Sendte {count} påminnelser om EU-kontroll",
-    "service_videoCall_send": "Sendte lenken til videosamtalen via {channels}"
+    "service_videoCall_send": "Sendte lenken til videosamtalen via {channels}",
+    "timeEntry_create": "La til {minutes} minutter for {technician} på «{job}»",
+    "timeEntry_update": "Korrigerte tidsoppføringen til {technician} på «{job}» til {minutes} minutter",
+    "timeEntry_stop": "Stoppet klokken til {technician} på «{job}» ved {minutes} minutter",
+    "timeEntry_delete": "Slettet tidsoppføring {id}"
   }
 }

+ 2 - 1
messages/nb/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Legg til {qty} i jobb",
     "statusReport": "Statusrapport",
     "report": "Rapport",
-    "observation": "Observasjon"
+    "observation": "Observasjon",
+    "empty": "Ingen arbeidsordrer er tildelt deg akkurat nå."
   },
   "recentActivity": "Nylig aktivitet",
   "noRecentActivity": "Ingen nylig aktivitet",

+ 2 - 0
messages/nb/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Alle deler",
     "laborPresets": "Arbeidsmaler",
     "reports": "Rapporter",
+    "timesheets": "Timelister",
     "workBoard": "Arbeidstavle",
     "presenter": "Presentasjon",
     "adminOverview": "Adminoversikt",
@@ -102,6 +103,7 @@
     "inventory": "Lager",
     "laborPresets": "Arbeidsmaler",
     "reports": "Rapporter",
+    "timesheets": "Timelister",
     "settings": "Innstillinger",
     "superAdmin": "Super Admin",
     "adminPanel": "Adminpanel",

+ 1 - 0
messages/nb/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Kontroller",
     "tire_hotel": "Dekkhotell",
     "reports": "Rapporter",
+    "time_tracking": "Tidsregistrering",
     "work_board": "Arbeidstavle",
     "ai_assistant": "AI-assistent",
     "settings": "Innstillinger"

+ 21 - 2
messages/nb/reports.json

@@ -12,7 +12,9 @@
     "parts": "Deler",
     "jobAnalytics": "Jobbanalyse",
     "retention": "Kundelojalitet",
-    "vehicles": "Kjøretøy"
+    "vehicles": "Kjøretøy",
+    "techniciansOverview": "Oversikt",
+    "clockedTime": "Registrert tid"
   },
   "dateRange": {
     "to": "til",
@@ -138,6 +140,19 @@
       "avgRevenue": "Gj.sn. Inntekt",
       "totalHours": "Totale Timer",
       "avgHours": "Gj.sn. Timer"
+    },
+    "time": {
+      "clockedHours": "Registrerte timer",
+      "billedHours": "Fakturerte timer",
+      "efficiency": "Effektivitet",
+      "techniciansClocked": "Teknikere med tid",
+      "chartTitle": "Registrert mot fakturert per tekniker",
+      "tableTitle": "Registrert mot fakturert",
+      "clocked": "Registrert",
+      "billed": "Fakturert",
+      "hint": "Registrerte timer kommer fra stemplingsklokken. Fakturerte timer er arbeidslinjene på jobber tildelt teknikeren som startet i perioden. Effektivitet er fakturert delt på registrert.",
+      "noClock": "Ingen tid",
+      "jobsClocked": "Jobber med tid"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Type",
     "status": "Status",
     "partsUsed": "Deler",
-    "laborHours": "Arbeidstimer"
+    "laborHours": "Arbeidstimer",
+    "clockedHours": "Registrerte timer",
+    "billedHours": "Fakturerte timer",
+    "efficiency": "Effektivitet",
+    "jobsClocked": "Jobber med tid"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/nb/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Klokken går på {job}",
+    "stop": "Stopp klokken",
+    "stopped": "{minutes} registrert",
+    "switched": "Stoppet forrige jobb ved {minutes}",
+    "startFailed": "Kunne ikke starte klokken",
+    "stopFailed": "Kunne ikke stoppe klokken"
+  },
+  "job": {
+    "title": "Registrert tid",
+    "running": "pågår",
+    "clockIn": "Stemple inn",
+    "clockOut": "Stemple ut",
+    "switchHere": "Bytt til denne jobben",
+    "switchHint": "Stopper klokken på {job} og starter den her",
+    "addAsLabor": "Legg til {hours, number} t som arbeid",
+    "addAsLaborHint": "Legger til en arbeidslinje på {hours, number} timer til standardsats. Den registrerte tiden endres ikke.",
+    "laborDescription": "Arbeid (registrert tid)",
+    "empty": "Ingen tid registrert på denne jobben ennå. Teknikere stempler inn fra appen eller fra Mine aktive jobber på dashbordet.",
+    "showAll": "Vis {count} til",
+    "showFewer": "Vis færre",
+    "openTimesheets": "Åpne timelister"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manuell",
+    "editedBy": "Justert av {name}"
+  },
+  "page": {
+    "title": "Timelister",
+    "description": "Hvem som jobbet når, etter verkstedets klokke.",
+    "loadFailed": "Kunne ikke laste timelisten",
+    "presets": {
+      "today": "I dag",
+      "yesterday": "I går",
+      "thisWeek": "Denne uken",
+      "lastWeek": "Forrige uke",
+      "thisMonth": "Denne måneden",
+      "lastMonth": "Forrige måned"
+    },
+    "customRange": "Egendefinert periode",
+    "apply": "Bruk",
+    "allTechnicians": "Alle teknikere",
+    "export": "Eksporter CSV",
+    "addEntry": "Legg til oppføring",
+    "stats": {
+      "total": "Total tid",
+      "totalHint": "{days, plural, =1 {1 dag} other {# dager}} i perioden",
+      "technicians": "Teknikere med tid",
+      "techniciansHint": "av {count} på laget",
+      "running": "Stemplet inn nå",
+      "runningHint": "direkte",
+      "average": "Snitt per aktiv dag",
+      "averageHint": "over {days, plural, =1 {1 dag} other {# dager}} med tid"
+    },
+    "nowStrip": {
+      "title": "Stemplet inn nå",
+      "since": "siden {time}",
+      "stop": "Stopp"
+    },
+    "grid": {
+      "title": "Oversikt",
+      "description": "Timer per tekniker per dag. Klikk på en celle for å hoppe til den dagen.",
+      "technician": "Tekniker",
+      "total": "Totalt",
+      "tooLong": "Oversikten viser opptil 31 dager. Snevre inn perioden for å se den."
+    },
+    "table": {
+      "time": "Tid",
+      "duration": "Varighet",
+      "job": "Jobb",
+      "vehicle": "Kjøretøy",
+      "source": "Kilde",
+      "note": "Notat",
+      "running": "Pågår",
+      "dayTotal": "Dagstotal",
+      "counterSale": "Disksalg",
+      "edit": "Rediger",
+      "stop": "Stopp klokken",
+      "delete": "Slett",
+      "actions": "Handlinger"
+    },
+    "empty": {
+      "title": "Ingen tid registrert i denne perioden",
+      "body": "Teknikere stempler inn fra Torqvoice Technicians-appen eller fra Mine aktive jobber på dashbordet. Oppføringer vises her i det øyeblikket en klokke starter.",
+      "noTechnicians": "Ingen på laget er satt opp som tekniker ennå. Koble teammedlemmer til teknikere under Innstillinger, Team.",
+      "teamLink": "Åpne teaminnstillinger"
+    },
+    "deleteTitle": "Slette denne oppføringen?",
+    "deleteBody": "{duration} på {job} av {technician} fjernes fra timelisten. Dette kan ikke angres.",
+    "deleteConfirm": "Slett",
+    "deleted": "Oppføring slettet",
+    "stoppedByManager": "Klokken stoppet ved {minutes}",
+    "csv": {
+      "technician": "Tekniker",
+      "date": "Dato",
+      "start": "Start",
+      "end": "Slutt",
+      "duration": "Varighet",
+      "hours": "Timer",
+      "job": "Jobb",
+      "vehicle": "Kjøretøy",
+      "plate": "Skilt",
+      "source": "Kilde",
+      "editedBy": "Justert av",
+      "note": "Notat"
+    },
+    "unlinked": "Ingen konto",
+    "unlinkedHint": "Kun på tavlen, ikke koblet til et teammedlem"
+  },
+  "dialog": {
+    "addTitle": "Legg til tidsoppføring",
+    "addDescription": "Tid en tekniker jobbet, men ikke stemplet. Den merkes som lagt inn for hånd.",
+    "editTitle": "Korriger tidsoppføring",
+    "editDescription": "Oppføringen beholder tekniker og jobb. Hvem som endret den, blir registrert.",
+    "technician": "Tekniker",
+    "pickTechnician": "Velg en tekniker",
+    "job": "Arbeidsordre",
+    "pickJob": "Velg en arbeidsordre",
+    "searchJobs": "Søk etter tittel eller skilt",
+    "searching": "Søker…",
+    "noJobs": "Fant ingen arbeidsordrer",
+    "start": "Start",
+    "end": "Slutt",
+    "stillRunning": "Går fortsatt",
+    "leaveEndEmpty": "La sluttiden stå tom for å la klokken gå videre.",
+    "endBeforeStart": "Slutt må være etter start.",
+    "duration": "Varighet: {duration}",
+    "timesInWorkshopZone": "Tidene følger verkstedets klokke ({zone}).",
+    "note": "Notat",
+    "notePlaceholder": "Valgfritt. Hvorfor dette ble lagt til eller endret.",
+    "cancel": "Avbryt",
+    "save": "Lagre",
+    "add": "Legg til",
+    "added": "Oppføring lagt til",
+    "updated": "Oppføring oppdatert",
+    "saveFailed": "Kunne ikke lagre oppføringen"
+  }
+}

+ 9 - 2
messages/nl/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Oudere facturen vergrendeld",
     "inspection_reminders_sent": "Keuringsherinneringen verstuurd",
     "inspection_reminders_send": "Keuringsherinneringen verstuurd",
-    "service_videoCall_send": "Link videogesprek verstuurd"
+    "service_videoCall_send": "Link videogesprek verstuurd",
+    "timeEntry_create": "Tijdregistratie toegevoegd",
+    "timeEntry_update": "Tijdregistratie gecorrigeerd",
+    "timeEntry_delete": "Tijdregistratie verwijderd"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Gewijzigd wanneer het documentontwerp {name} wordt gebruikt",
     "settings_freezeInvoices": "Vergrendelde {count, plural, one {# factuur} other {# facturen}} van vóór het vergrendelen met het huidige ontwerp",
     "inspection_reminders_sent": "{count} keuringsherinneringen verstuurd",
-    "service_videoCall_send": "Heeft de link van het videogesprek verstuurd via {channels}"
+    "service_videoCall_send": "Heeft de link van het videogesprek verstuurd via {channels}",
+    "timeEntry_create": "{minutes} minuten toegevoegd voor {technician} op \"{job}\"",
+    "timeEntry_update": "Tijdregistratie van {technician} op \"{job}\" gecorrigeerd naar {minutes} minuten",
+    "timeEntry_stop": "Klok van {technician} op \"{job}\" gestopt op {minutes} minuten",
+    "timeEntry_delete": "Tijdregistratie {id} verwijderd"
   }
 }

+ 2 - 1
messages/nl/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "{qty} toevoegen aan klus",
     "statusReport": "Statusrapport",
     "report": "Rapport",
-    "observation": "Observatie"
+    "observation": "Observatie",
+    "empty": "Er zijn op dit moment geen werkorders aan je toegewezen."
   },
   "recentActivity": "Recente activiteit",
   "noRecentActivity": "Geen recente activiteit",

+ 2 - 0
messages/nl/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Alle onderdelen",
     "laborPresets": "Arbeidssjablonen",
     "reports": "Rapporten",
+    "timesheets": "Urenstaten",
     "workBoard": "Werkbord",
     "presenter": "Presentator",
     "adminOverview": "Beheeroverzicht",
@@ -102,6 +103,7 @@
     "inventory": "Voorraad",
     "laborPresets": "Arbeidssjablonen",
     "reports": "Rapporten",
+    "timesheets": "Urenstaten",
     "settings": "Instellingen",
     "superAdmin": "Super Admin",
     "adminPanel": "Beheerpaneel",

+ 1 - 0
messages/nl/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Inspecties",
     "tire_hotel": "Bandenhotel",
     "reports": "Rapporten",
+    "time_tracking": "Tijdregistratie",
     "work_board": "Werkbord",
     "ai_assistant": "AI-assistent",
     "settings": "Instellingen"

+ 21 - 2
messages/nl/reports.json

@@ -12,7 +12,9 @@
     "parts": "Onderdelen",
     "jobAnalytics": "Opdrachtanalyse",
     "retention": "Klantbehoud",
-    "vehicles": "Voertuigen"
+    "vehicles": "Voertuigen",
+    "techniciansOverview": "Overzicht",
+    "clockedTime": "Geklokte tijd"
   },
   "dateRange": {
     "to": "tot",
@@ -138,6 +140,19 @@
       "avgRevenue": "Gem. Omzet",
       "totalHours": "Totale Uren",
       "avgHours": "Gem. Uren"
+    },
+    "time": {
+      "clockedHours": "Geklokte uren",
+      "billedHours": "Gefactureerde uren",
+      "efficiency": "Efficiëntie",
+      "techniciansClocked": "Monteurs met tijd",
+      "chartTitle": "Geklokt versus gefactureerd per monteur",
+      "tableTitle": "Geklokt tegenover gefactureerd",
+      "clocked": "Geklokt",
+      "billed": "Gefactureerd",
+      "hint": "Geklokte uren komen van de prikklok. Gefactureerde uren zijn de arbeidsregels op klussen die aan de monteur zijn toegewezen en in de periode zijn gestart. Efficiëntie is gefactureerd gedeeld door geklokt.",
+      "noClock": "Geen tijd",
+      "jobsClocked": "Geklokte klussen"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Type",
     "status": "Status",
     "partsUsed": "Onderdelen",
-    "laborHours": "Arbeidsuren"
+    "laborHours": "Arbeidsuren",
+    "clockedHours": "Geklokte uren",
+    "billedHours": "Gefactureerde uren",
+    "efficiency": "Efficiëntie",
+    "jobsClocked": "Geklokte klussen"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/nl/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Klok loopt op {job}",
+    "stop": "Klok stoppen",
+    "stopped": "{minutes} geregistreerd",
+    "switched": "Vorige klus gestopt op {minutes}",
+    "startFailed": "Kon de klok niet starten",
+    "stopFailed": "Kon de klok niet stoppen"
+  },
+  "job": {
+    "title": "Geklokte tijd",
+    "running": "loopt",
+    "clockIn": "Inklokken",
+    "clockOut": "Uitklokken",
+    "switchHere": "Overschakelen naar deze klus",
+    "switchHint": "Stopt de klok op {job} en start hem hier",
+    "addAsLabor": "{hours, number} u als arbeid toevoegen",
+    "addAsLaborHint": "Voegt een arbeidsregel van {hours, number} uur toe tegen het standaardtarief. De geklokte tijd zelf verandert niet.",
+    "laborDescription": "Arbeid (geklokte tijd)",
+    "empty": "Nog geen tijd geklokt op deze klus. Monteurs klokken in vanuit de app of via Mijn actieve klussen op het dashboard.",
+    "showAll": "Nog {count} tonen",
+    "showFewer": "Minder tonen",
+    "openTimesheets": "Urenstaten openen"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Handmatig",
+    "editedBy": "Aangepast door {name}"
+  },
+  "page": {
+    "title": "Urenstaten",
+    "description": "Wie wanneer werkte, volgens de klok van de werkplaats.",
+    "loadFailed": "Kon de urenstaat niet laden",
+    "presets": {
+      "today": "Vandaag",
+      "yesterday": "Gisteren",
+      "thisWeek": "Deze week",
+      "lastWeek": "Vorige week",
+      "thisMonth": "Deze maand",
+      "lastMonth": "Vorige maand"
+    },
+    "customRange": "Aangepaste periode",
+    "apply": "Toepassen",
+    "allTechnicians": "Alle monteurs",
+    "export": "CSV exporteren",
+    "addEntry": "Registratie toevoegen",
+    "stats": {
+      "total": "Totale tijd",
+      "totalHint": "{days, plural, =1 {1 dag} other {# dagen}} in de periode",
+      "technicians": "Monteurs met tijd",
+      "techniciansHint": "van {count} in het team",
+      "running": "Nu ingeklokt",
+      "runningHint": "live",
+      "average": "Gemiddeld per actieve dag",
+      "averageHint": "over {days, plural, =1 {1 dag} other {# dagen}} met tijd"
+    },
+    "nowStrip": {
+      "title": "Nu ingeklokt",
+      "since": "sinds {time}",
+      "stop": "Stoppen"
+    },
+    "grid": {
+      "title": "Overzicht",
+      "description": "Uren per monteur per dag. Klik op een cel om naar die dag te springen.",
+      "technician": "Monteur",
+      "total": "Totaal",
+      "tooLong": "Het overzicht toont maximaal 31 dagen. Verklein de periode om het te zien."
+    },
+    "table": {
+      "time": "Tijd",
+      "duration": "Duur",
+      "job": "Klus",
+      "vehicle": "Voertuig",
+      "source": "Bron",
+      "note": "Notitie",
+      "running": "Loopt",
+      "dayTotal": "Dagtotaal",
+      "counterSale": "Balieverkoop",
+      "edit": "Bewerken",
+      "stop": "Klok stoppen",
+      "delete": "Verwijderen",
+      "actions": "Acties"
+    },
+    "empty": {
+      "title": "Geen tijd geklokt in deze periode",
+      "body": "Monteurs klokken in vanuit de Torqvoice Technicians-app of via Mijn actieve klussen op het dashboard. Registraties verschijnen hier zodra een klok start.",
+      "noTechnicians": "Nog niemand in het team is ingesteld als monteur. Koppel teamleden aan monteurs onder Instellingen, Team.",
+      "teamLink": "Teaminstellingen openen"
+    },
+    "deleteTitle": "Deze registratie verwijderen?",
+    "deleteBody": "{duration} op {job} door {technician} wordt van de urenstaat verwijderd. Dit kan niet ongedaan worden gemaakt.",
+    "deleteConfirm": "Verwijderen",
+    "deleted": "Registratie verwijderd",
+    "stoppedByManager": "Klok gestopt op {minutes}",
+    "csv": {
+      "technician": "Monteur",
+      "date": "Datum",
+      "start": "Start",
+      "end": "Einde",
+      "duration": "Duur",
+      "hours": "Uren",
+      "job": "Klus",
+      "vehicle": "Voertuig",
+      "plate": "Kenteken",
+      "source": "Bron",
+      "editedBy": "Aangepast door",
+      "note": "Notitie"
+    },
+    "unlinked": "Geen account",
+    "unlinkedHint": "Alleen op het bord, niet gekoppeld aan een teamlid"
+  },
+  "dialog": {
+    "addTitle": "Tijdregistratie toevoegen",
+    "addDescription": "Tijd die een monteur werkte maar niet klokte. Wordt gemarkeerd als handmatig ingevoerd.",
+    "editTitle": "Tijdregistratie corrigeren",
+    "editDescription": "De registratie behoudt monteur en klus. Wie het wijzigde, wordt vastgelegd.",
+    "technician": "Monteur",
+    "pickTechnician": "Kies een monteur",
+    "job": "Werkorder",
+    "pickJob": "Kies een werkorder",
+    "searchJobs": "Zoek op titel of kenteken",
+    "searching": "Zoeken…",
+    "noJobs": "Geen werkorders gevonden",
+    "start": "Start",
+    "end": "Einde",
+    "stillRunning": "Loopt nog",
+    "leaveEndEmpty": "Laat het einde leeg om de klok te laten doorlopen.",
+    "endBeforeStart": "Het einde moet na de start liggen.",
+    "duration": "Duur: {duration}",
+    "timesInWorkshopZone": "Tijden volgen de klok van de werkplaats ({zone}).",
+    "note": "Notitie",
+    "notePlaceholder": "Optioneel. Waarom dit is toegevoegd of gewijzigd.",
+    "cancel": "Annuleren",
+    "save": "Opslaan",
+    "add": "Toevoegen",
+    "added": "Registratie toegevoegd",
+    "updated": "Registratie bijgewerkt",
+    "saveFailed": "Kon de registratie niet opslaan"
+  }
+}

+ 9 - 2
messages/pl/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Zablokowano starsze faktury",
     "inspection_reminders_sent": "Wysłano przypomnienia o przeglądzie",
     "inspection_reminders_send": "Wysłano przypomnienia o przeglądzie",
-    "service_videoCall_send": "Wysłano link do rozmowy wideo"
+    "service_videoCall_send": "Wysłano link do rozmowy wideo",
+    "timeEntry_create": "Dodano wpis czasu",
+    "timeEntry_update": "Poprawiono wpis czasu",
+    "timeEntry_delete": "Usunięto wpis czasu"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt dokumentu {name}",
     "settings_freezeInvoices": "Zablokował {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}} sprzed blokady z bieżącym projektem",
     "inspection_reminders_sent": "Wysłano {count} przypomnień o przeglądzie",
-    "service_videoCall_send": "Wysłano link do rozmowy wideo przez {channels}"
+    "service_videoCall_send": "Wysłano link do rozmowy wideo przez {channels}",
+    "timeEntry_create": "Dodano {minutes} min dla {technician} w zleceniu „{job}”",
+    "timeEntry_update": "Poprawiono wpis czasu {technician} w zleceniu „{job}” na {minutes} min",
+    "timeEntry_stop": "Zatrzymano zegar {technician} w zleceniu „{job}” na {minutes} min",
+    "timeEntry_delete": "Usunięto wpis czasu {id}"
   }
 }

+ 2 - 1
messages/pl/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Dodaj {qty} do zlecenia",
     "statusReport": "Raport statusu",
     "report": "Raport",
-    "observation": "Obserwacja"
+    "observation": "Obserwacja",
+    "empty": "Obecnie nie masz przypisanych zleceń."
   },
   "recentActivity": "Ostatnia aktywność",
   "noRecentActivity": "Brak ostatniej aktywności",

+ 2 - 0
messages/pl/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Wszystkie części",
     "laborPresets": "Szablony robocizny",
     "reports": "Raporty",
+    "timesheets": "Ewidencja czasu",
     "workBoard": "Tablica zleceń",
     "presenter": "Prezenter",
     "adminOverview": "Przegląd administracyjny",
@@ -102,6 +103,7 @@
     "inventory": "Magazyn",
     "laborPresets": "Szablony robocizny",
     "reports": "Raporty",
+    "timesheets": "Ewidencja czasu",
     "settings": "Ustawienia",
     "superAdmin": "Super Admin",
     "adminPanel": "Panel administracyjny",

+ 1 - 0
messages/pl/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Przeglądy",
     "tire_hotel": "Hotel opon",
     "reports": "Raporty",
+    "time_tracking": "Rejestracja czasu",
     "work_board": "Tablica pracy",
     "ai_assistant": "Asystent AI",
     "settings": "Ustawienia"

+ 21 - 2
messages/pl/reports.json

@@ -12,7 +12,9 @@
     "parts": "Części",
     "jobAnalytics": "Analiza Zleceń",
     "retention": "Utrzymanie Klientów",
-    "vehicles": "Pojazdy"
+    "vehicles": "Pojazdy",
+    "techniciansOverview": "Przegląd",
+    "clockedTime": "Zarejestrowany czas"
   },
   "dateRange": {
     "to": "do",
@@ -138,6 +140,19 @@
       "avgRevenue": "Śr. Przychód",
       "totalHours": "Łączne Godziny",
       "avgHours": "Śr. Godziny"
+    },
+    "time": {
+      "clockedHours": "Godziny zarejestrowane",
+      "billedHours": "Godziny zafakturowane",
+      "efficiency": "Efektywność",
+      "techniciansClocked": "Technicy z czasem",
+      "chartTitle": "Zarejestrowane i zafakturowane wg technika",
+      "tableTitle": "Zarejestrowane a zafakturowane",
+      "clocked": "Zarejestrowane",
+      "billed": "Zafakturowane",
+      "hint": "Godziny zarejestrowane pochodzą z zegara. Godziny zafakturowane to pozycje robocizny w zleceniach przypisanych technikowi, rozpoczętych w zakresie. Efektywność to zafakturowane podzielone przez zarejestrowane.",
+      "noClock": "Brak czasu",
+      "jobsClocked": "Zlecenia z czasem"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Typ",
     "status": "Status",
     "partsUsed": "Części",
-    "laborHours": "Godziny Robocizny"
+    "laborHours": "Godziny Robocizny",
+    "clockedHours": "Godziny zarejestrowane",
+    "billedHours": "Godziny zafakturowane",
+    "efficiency": "Efektywność",
+    "jobsClocked": "Zlecenia z czasem"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/pl/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Zegar działa w zleceniu {job}",
+    "stop": "Zatrzymaj zegar",
+    "stopped": "Zarejestrowano {minutes}",
+    "switched": "Poprzednie zlecenie zatrzymano na {minutes}",
+    "startFailed": "Nie udało się uruchomić zegara",
+    "stopFailed": "Nie udało się zatrzymać zegara"
+  },
+  "job": {
+    "title": "Zarejestrowany czas",
+    "running": "w toku",
+    "clockIn": "Rozpocznij pracę",
+    "clockOut": "Zakończ pracę",
+    "switchHere": "Przełącz na to zlecenie",
+    "switchHint": "Zatrzymuje zegar w zleceniu {job} i uruchamia go tutaj",
+    "addAsLabor": "Dodaj {hours, number} h jako robociznę",
+    "addAsLaborHint": "Dodaje pozycję robocizny {hours, number} godz. według stawki domyślnej. Zarejestrowany czas nie zmienia się.",
+    "laborDescription": "Robocizna (zarejestrowany czas)",
+    "empty": "W tym zleceniu nie zarejestrowano jeszcze czasu. Technicy rozpoczynają pracę w aplikacji lub w sekcji Moje aktywne zlecenia na pulpicie.",
+    "showAll": "Pokaż jeszcze {count}",
+    "showFewer": "Pokaż mniej",
+    "openTimesheets": "Otwórz ewidencję czasu"
+  },
+  "source": {
+    "app": "Aplikacja",
+    "web": "Web",
+    "manual": "Ręcznie",
+    "editedBy": "Poprawione przez {name}"
+  },
+  "page": {
+    "title": "Ewidencja czasu",
+    "description": "Kto i kiedy pracował, według zegara warsztatu.",
+    "loadFailed": "Nie udało się wczytać ewidencji",
+    "presets": {
+      "today": "Dziś",
+      "yesterday": "Wczoraj",
+      "thisWeek": "Ten tydzień",
+      "lastWeek": "Poprzedni tydzień",
+      "thisMonth": "Ten miesiąc",
+      "lastMonth": "Poprzedni miesiąc"
+    },
+    "customRange": "Własny zakres",
+    "apply": "Zastosuj",
+    "allTechnicians": "Wszyscy technicy",
+    "export": "Eksportuj CSV",
+    "addEntry": "Dodaj wpis",
+    "stats": {
+      "total": "Łączny czas",
+      "totalHint": "{days, plural, =1 {1 dzień} few {# dni} other {# dni}} w zakresie",
+      "technicians": "Technicy z czasem",
+      "techniciansHint": "z {count} w zespole",
+      "running": "Pracują teraz",
+      "runningHint": "na żywo",
+      "average": "Średnio na aktywny dzień",
+      "averageHint": "w {days, plural, =1 {1 dniu} few {# dniach} other {# dniach}} z czasem"
+    },
+    "nowStrip": {
+      "title": "Pracują teraz",
+      "since": "od {time}",
+      "stop": "Zatrzymaj"
+    },
+    "grid": {
+      "title": "Przegląd",
+      "description": "Godziny na technika na dzień. Kliknij komórkę, aby przejść do tego dnia.",
+      "technician": "Technik",
+      "total": "Razem",
+      "tooLong": "Przegląd pokazuje do 31 dni. Zawęź zakres, aby go zobaczyć."
+    },
+    "table": {
+      "time": "Czas",
+      "duration": "Czas trwania",
+      "job": "Zlecenie",
+      "vehicle": "Pojazd",
+      "source": "Źródło",
+      "note": "Notatka",
+      "running": "W toku",
+      "dayTotal": "Suma dnia",
+      "counterSale": "Sprzedaż przy ladzie",
+      "edit": "Edytuj",
+      "stop": "Zatrzymaj zegar",
+      "delete": "Usuń",
+      "actions": "Akcje"
+    },
+    "empty": {
+      "title": "Brak zarejestrowanego czasu w tym zakresie",
+      "body": "Technicy rozpoczynają pracę w aplikacji Torqvoice Technicians lub w sekcji Moje aktywne zlecenia na pulpicie. Wpisy pojawiają się tutaj, gdy tylko zegar wystartuje.",
+      "noTechnicians": "Nikt w zespole nie jest jeszcze ustawiony jako technik. Powiąż członków zespołu z technikami w Ustawienia, Zespół.",
+      "teamLink": "Otwórz ustawienia zespołu"
+    },
+    "deleteTitle": "Usunąć ten wpis?",
+    "deleteBody": "{duration} w zleceniu {job} technika {technician} zostanie usunięte z ewidencji. Tej operacji nie można cofnąć.",
+    "deleteConfirm": "Usuń",
+    "deleted": "Wpis usunięty",
+    "stoppedByManager": "Zegar zatrzymany na {minutes}",
+    "csv": {
+      "technician": "Technik",
+      "date": "Data",
+      "start": "Początek",
+      "end": "Koniec",
+      "duration": "Czas trwania",
+      "hours": "Godziny",
+      "job": "Zlecenie",
+      "vehicle": "Pojazd",
+      "plate": "Rejestracja",
+      "source": "Źródło",
+      "editedBy": "Poprawione przez",
+      "note": "Notatka"
+    },
+    "unlinked": "Brak konta",
+    "unlinkedHint": "Technik tylko na tablicy, niepowiązany z członkiem zespołu"
+  },
+  "dialog": {
+    "addTitle": "Dodaj wpis czasu",
+    "addDescription": "Czas, który technik przepracował, ale nie zarejestrował. Wpis jest oznaczony jako wprowadzony ręcznie.",
+    "editTitle": "Popraw wpis czasu",
+    "editDescription": "Wpis zachowuje technika i zlecenie. Zapisywane jest, kto go zmienił.",
+    "technician": "Technik",
+    "pickTechnician": "Wybierz technika",
+    "job": "Zlecenie",
+    "pickJob": "Wybierz zlecenie",
+    "searchJobs": "Szukaj po tytule lub rejestracji",
+    "searching": "Szukanie…",
+    "noJobs": "Nie znaleziono zleceń",
+    "start": "Początek",
+    "end": "Koniec",
+    "stillRunning": "Nadal trwa",
+    "leaveEndEmpty": "Zostaw koniec pusty, aby zegar dalej działał.",
+    "endBeforeStart": "Koniec musi być po początku.",
+    "duration": "Czas trwania: {duration}",
+    "timesInWorkshopZone": "Godziny według zegara warsztatu ({zone}).",
+    "note": "Notatka",
+    "notePlaceholder": "Opcjonalnie. Dlaczego to dodano lub zmieniono.",
+    "cancel": "Anuluj",
+    "save": "Zapisz",
+    "add": "Dodaj",
+    "added": "Wpis dodany",
+    "updated": "Wpis zaktualizowany",
+    "saveFailed": "Nie udało się zapisać wpisu"
+  }
+}

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

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Faturas anteriores travadas",
     "inspection_reminders_sent": "Enviou lembretes de inspeção",
     "inspection_reminders_send": "Enviou lembretes de inspeção",
-    "service_videoCall_send": "Link da videochamada enviado"
+    "service_videoCall_send": "Link da videochamada enviado",
+    "timeEntry_create": "Registro de tempo adicionado",
+    "timeEntry_update": "Registro de tempo corrigido",
+    "timeEntry_delete": "Registro de tempo excluído"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Alterou quando o design de documento {name} é usado",
     "settings_freezeInvoices": "Travou {count, plural, one {# fatura} other {# faturas}} anteriores ao bloqueio com o design atual",
     "inspection_reminders_sent": "Enviou {count} lembretes de inspeção",
-    "service_videoCall_send": "Enviou o link da videochamada por {channels}"
+    "service_videoCall_send": "Enviou o link da videochamada por {channels}",
+    "timeEntry_create": "Adicionados {minutes} minutos para {technician} em \"{job}\"",
+    "timeEntry_update": "Registro de tempo de {technician} em \"{job}\" corrigido para {minutes} minutos",
+    "timeEntry_stop": "Relógio de {technician} em \"{job}\" parado em {minutes} minutos",
+    "timeEntry_delete": "Registro de tempo {id} excluído"
   }
 }

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

@@ -19,7 +19,8 @@
     "addToJob": "Adicionar {qty} ao serviço",
     "statusReport": "Relatório de Status",
     "report": "Relatório",
-    "observation": "Observação"
+    "observation": "Observação",
+    "empty": "Nenhuma ordem de serviço está atribuída a você no momento."
   },
   "recentActivity": "Atividade recente",
   "noRecentActivity": "Nenhuma atividade recente",

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

@@ -20,6 +20,7 @@
     "allParts": "Todas as Peças",
     "laborPresets": "Modelos de mão de obra",
     "reports": "Relatórios",
+    "timesheets": "Folhas de ponto",
     "workBoard": "Quadro de Trabalho",
     "presenter": "Apresentador",
     "adminOverview": "Visão Geral do Admin",
@@ -102,6 +103,7 @@
     "inventory": "Estoque",
     "laborPresets": "Modelos de mão de obra",
     "reports": "Relatórios",
+    "timesheets": "Folhas de ponto",
     "settings": "Configurações",
     "superAdmin": "Super Admin",
     "adminPanel": "Painel Admin",

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

@@ -12,6 +12,7 @@
     "inspections": "Inspeções",
     "tire_hotel": "Hotel de pneus",
     "reports": "Relatórios",
+    "time_tracking": "Controle de tempo",
     "work_board": "Quadro de trabalho",
     "ai_assistant": "Assistente de IA",
     "settings": "Configurações"

+ 21 - 2
messages/pt-BR/reports.json

@@ -12,7 +12,9 @@
     "parts": "Peças",
     "jobAnalytics": "Análise de Trabalhos",
     "retention": "Retenção",
-    "vehicles": "Veículos"
+    "vehicles": "Veículos",
+    "techniciansOverview": "Visão geral",
+    "clockedTime": "Tempo registrado"
   },
   "dateRange": {
     "to": "até",
@@ -138,6 +140,19 @@
       "avgRevenue": "Receita Méd.",
       "totalHours": "Horas Totais",
       "avgHours": "Horas Méd."
+    },
+    "time": {
+      "clockedHours": "Horas registradas",
+      "billedHours": "Horas faturadas",
+      "efficiency": "Eficiência",
+      "techniciansClocked": "Técnicos com registro",
+      "chartTitle": "Registrado x faturado por técnico",
+      "tableTitle": "Registrado contra faturado",
+      "clocked": "Registrado",
+      "billed": "Faturado",
+      "hint": "As horas registradas vêm do relógio de ponto. As horas faturadas são as linhas de mão de obra dos serviços atribuídos ao técnico iniciados no período. A eficiência é faturado dividido por registrado.",
+      "noClock": "Sem registro",
+      "jobsClocked": "Serviços registrados"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Tipo",
     "status": "Status",
     "partsUsed": "Peças",
-    "laborHours": "Horas de Mão de Obra"
+    "laborHours": "Horas de Mão de Obra",
+    "clockedHours": "Horas registradas",
+    "billedHours": "Horas faturadas",
+    "efficiency": "Eficiência",
+    "jobsClocked": "Serviços registrados"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/pt-BR/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Relógio rodando em {job}",
+    "stop": "Parar o relógio",
+    "stopped": "{minutes} registrados",
+    "switched": "Serviço anterior parado em {minutes}",
+    "startFailed": "Não foi possível iniciar o relógio",
+    "stopFailed": "Não foi possível parar o relógio"
+  },
+  "job": {
+    "title": "Tempo registrado",
+    "running": "em andamento",
+    "clockIn": "Bater ponto",
+    "clockOut": "Encerrar ponto",
+    "switchHere": "Mudar para este serviço",
+    "switchHint": "Para o relógio em {job} e inicia aqui",
+    "addAsLabor": "Adicionar {hours, number} h como mão de obra",
+    "addAsLaborHint": "Adiciona uma linha de mão de obra de {hours, number} horas na tarifa padrão. O tempo registrado não muda.",
+    "laborDescription": "Mão de obra (tempo registrado)",
+    "empty": "Nenhum tempo registrado neste serviço ainda. Os técnicos batem ponto pelo app ou em Meus serviços ativos no painel.",
+    "showAll": "Mostrar mais {count}",
+    "showFewer": "Mostrar menos",
+    "openTimesheets": "Abrir folhas de ponto"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manual",
+    "editedBy": "Ajustado por {name}"
+  },
+  "page": {
+    "title": "Folhas de ponto",
+    "description": "Quem trabalhou e quando, pelo relógio da oficina.",
+    "loadFailed": "Não foi possível carregar a folha de ponto",
+    "presets": {
+      "today": "Hoje",
+      "yesterday": "Ontem",
+      "thisWeek": "Esta semana",
+      "lastWeek": "Semana passada",
+      "thisMonth": "Este mês",
+      "lastMonth": "Mês passado"
+    },
+    "customRange": "Período personalizado",
+    "apply": "Aplicar",
+    "allTechnicians": "Todos os técnicos",
+    "export": "Exportar CSV",
+    "addEntry": "Adicionar registro",
+    "stats": {
+      "total": "Tempo total",
+      "totalHint": "{days, plural, =1 {1 dia} other {# dias}} no período",
+      "technicians": "Técnicos com tempo",
+      "techniciansHint": "de {count} na equipe",
+      "running": "Em ponto agora",
+      "runningHint": "ao vivo",
+      "average": "Média por dia ativo",
+      "averageHint": "em {days, plural, =1 {1 dia} other {# dias}} com tempo"
+    },
+    "nowStrip": {
+      "title": "Em ponto agora",
+      "since": "desde {time}",
+      "stop": "Parar"
+    },
+    "grid": {
+      "title": "Visão geral",
+      "description": "Horas por técnico por dia. Clique em uma célula para ir até aquele dia.",
+      "technician": "Técnico",
+      "total": "Total",
+      "tooLong": "A visão geral mostra até 31 dias. Reduza o período para vê-la."
+    },
+    "table": {
+      "time": "Hora",
+      "duration": "Duração",
+      "job": "Serviço",
+      "vehicle": "Veículo",
+      "source": "Origem",
+      "note": "Observação",
+      "running": "Em andamento",
+      "dayTotal": "Total do dia",
+      "counterSale": "Venda no balcão",
+      "edit": "Editar",
+      "stop": "Parar o relógio",
+      "delete": "Excluir",
+      "actions": "Ações"
+    },
+    "empty": {
+      "title": "Nenhum tempo registrado neste período",
+      "body": "Os técnicos batem ponto pelo app Torqvoice Technicians ou em Meus serviços ativos no painel. Os registros aparecem aqui assim que um relógio inicia.",
+      "noTechnicians": "Ninguém da equipe está configurado como técnico ainda. Vincule membros da equipe a técnicos em Configurações, Equipe.",
+      "teamLink": "Abrir configurações da equipe"
+    },
+    "deleteTitle": "Excluir este registro?",
+    "deleteBody": "{duration} em {job} por {technician} será removido da folha de ponto. Isso não pode ser desfeito.",
+    "deleteConfirm": "Excluir",
+    "deleted": "Registro excluído",
+    "stoppedByManager": "Relógio parado em {minutes}",
+    "csv": {
+      "technician": "Técnico",
+      "date": "Data",
+      "start": "Início",
+      "end": "Fim",
+      "duration": "Duração",
+      "hours": "Horas",
+      "job": "Serviço",
+      "vehicle": "Veículo",
+      "plate": "Placa",
+      "source": "Origem",
+      "editedBy": "Ajustado por",
+      "note": "Observação"
+    },
+    "unlinked": "Sem conta",
+    "unlinkedHint": "Técnico apenas do quadro, não vinculado a um membro da equipe"
+  },
+  "dialog": {
+    "addTitle": "Adicionar registro de tempo",
+    "addDescription": "Tempo que um técnico trabalhou mas não registrou. Fica marcado como inserido manualmente.",
+    "editTitle": "Corrigir registro de tempo",
+    "editDescription": "O registro mantém o técnico e o serviço. Quem alterou fica gravado.",
+    "technician": "Técnico",
+    "pickTechnician": "Escolha um técnico",
+    "job": "Ordem de serviço",
+    "pickJob": "Escolha uma ordem de serviço",
+    "searchJobs": "Buscar por título ou placa",
+    "searching": "Buscando…",
+    "noJobs": "Nenhuma ordem de serviço encontrada",
+    "start": "Início",
+    "end": "Fim",
+    "stillRunning": "Ainda em andamento",
+    "leaveEndEmpty": "Deixe o fim vazio para manter o relógio rodando.",
+    "endBeforeStart": "O fim deve ser depois do início.",
+    "duration": "Duração: {duration}",
+    "timesInWorkshopZone": "Os horários seguem o relógio da oficina ({zone}).",
+    "note": "Observação",
+    "notePlaceholder": "Opcional. Por que isso foi adicionado ou alterado.",
+    "cancel": "Cancelar",
+    "save": "Salvar",
+    "add": "Adicionar",
+    "added": "Registro adicionado",
+    "updated": "Registro atualizado",
+    "saveFailed": "Não foi possível salvar o registro"
+  }
+}

+ 9 - 2
messages/ru/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Зафиксированы старые счета",
     "inspection_reminders_sent": "Отправлены напоминания о техосмотре",
     "inspection_reminders_send": "Отправлены напоминания о техосмотре",
-    "service_videoCall_send": "Отправлена ссылка на видеозвонок"
+    "service_videoCall_send": "Отправлена ссылка на видеозвонок",
+    "timeEntry_create": "Добавлена запись времени",
+    "timeEntry_update": "Исправлена запись времени",
+    "timeEntry_delete": "Удалена запись времени"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "Изменено, когда используется дизайн документа {name}",
     "settings_freezeInvoices": "Зафиксировал {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}} до появления блокировки с текущим дизайном",
     "inspection_reminders_sent": "Отправлено напоминаний о техосмотре: {count}",
-    "service_videoCall_send": "Отправил(а) ссылку на видеозвонок через {channels}"
+    "service_videoCall_send": "Отправил(а) ссылку на видеозвонок через {channels}",
+    "timeEntry_create": "Добавлено {minutes} мин для {technician} по заказу «{job}»",
+    "timeEntry_update": "Запись времени {technician} по заказу «{job}» исправлена на {minutes} мин",
+    "timeEntry_stop": "Таймер {technician} по заказу «{job}» остановлен на {minutes} мин",
+    "timeEntry_delete": "Удалена запись времени {id}"
   }
 }

+ 2 - 1
messages/ru/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Добавить {qty} к заказу",
     "statusReport": "Отчёт о состоянии",
     "report": "Отчёт",
-    "observation": "Наблюдение"
+    "observation": "Наблюдение",
+    "empty": "Сейчас вам не назначено ни одного заказ-наряда."
   },
   "recentActivity": "Последняя активность",
   "noRecentActivity": "Нет недавней активности",

+ 2 - 0
messages/ru/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Все запчасти",
     "laborPresets": "Шаблоны работ",
     "reports": "Отчёты",
+    "timesheets": "Табели",
     "workBoard": "Доска работ",
     "presenter": "Презентация",
     "adminOverview": "Обзор администратора",
@@ -102,6 +103,7 @@
     "inventory": "Склад",
     "laborPresets": "Шаблоны работ",
     "reports": "Отчёты",
+    "timesheets": "Табели",
     "settings": "Настройки",
     "superAdmin": "Суперадмин",
     "adminPanel": "Панель администратора",

+ 1 - 0
messages/ru/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Осмотры",
     "tire_hotel": "Шинный отель",
     "reports": "Отчёты",
+    "time_tracking": "Учёт времени",
     "work_board": "Доска работ",
     "ai_assistant": "ИИ-помощник",
     "settings": "Настройки"

+ 21 - 2
messages/ru/reports.json

@@ -12,7 +12,9 @@
     "parts": "Запчасти",
     "jobAnalytics": "Аналитика работ",
     "retention": "Удержание",
-    "vehicles": "Транспорт"
+    "vehicles": "Транспорт",
+    "techniciansOverview": "Обзор",
+    "clockedTime": "Учтённое время"
   },
   "dateRange": {
     "to": "до",
@@ -138,6 +140,19 @@
       "avgRevenue": "Средний доход",
       "totalHours": "Всего часов",
       "avgHours": "Среднее часов"
+    },
+    "time": {
+      "clockedHours": "Учтённые часы",
+      "billedHours": "Выставленные часы",
+      "efficiency": "Эффективность",
+      "techniciansClocked": "Техники с временем",
+      "chartTitle": "Учтено и выставлено по техникам",
+      "tableTitle": "Учтено против выставлено",
+      "clocked": "Учтено",
+      "billed": "Выставлено",
+      "hint": "Учтённые часы берутся из таймера. Выставленные часы — строки работ по заказам, назначенным технику и начатым в периоде. Эффективность — выставлено, делённое на учтено.",
+      "noClock": "Нет времени",
+      "jobsClocked": "Заказы с временем"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Тип",
     "status": "Статус",
     "partsUsed": "Запчасти",
-    "laborHours": "Часы работы"
+    "laborHours": "Часы работы",
+    "clockedHours": "Учтённые часы",
+    "billedHours": "Выставленные часы",
+    "efficiency": "Эффективность",
+    "jobsClocked": "Заказы с временем"
   },
   "pdf": {
     "reportTitle": "Бизнес-отчёт",

+ 141 - 0
messages/ru/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Таймер идёт по заказу {job}",
+    "stop": "Остановить таймер",
+    "stopped": "Учтено {minutes}",
+    "switched": "Предыдущий заказ остановлен на {minutes}",
+    "startFailed": "Не удалось запустить таймер",
+    "stopFailed": "Не удалось остановить таймер"
+  },
+  "job": {
+    "title": "Учтённое время",
+    "running": "идёт",
+    "clockIn": "Начать работу",
+    "clockOut": "Закончить работу",
+    "switchHere": "Переключиться на этот заказ",
+    "switchHint": "Останавливает таймер по заказу {job} и запускает его здесь",
+    "addAsLabor": "Добавить {hours, number} ч как работу",
+    "addAsLaborHint": "Добавляет строку работ на {hours, number} ч по ставке по умолчанию. Само учтённое время не меняется.",
+    "laborDescription": "Работа (учтённое время)",
+    "empty": "По этому заказу время ещё не учтено. Техники начинают учёт в приложении или в разделе «Мои активные заказы» на панели.",
+    "showAll": "Показать ещё {count}",
+    "showFewer": "Показать меньше",
+    "openTimesheets": "Открыть табели"
+  },
+  "source": {
+    "app": "Приложение",
+    "web": "Веб",
+    "manual": "Вручную",
+    "editedBy": "Исправлено: {name}"
+  },
+  "page": {
+    "title": "Табели",
+    "description": "Кто и когда работал, по часам мастерской.",
+    "loadFailed": "Не удалось загрузить табель",
+    "presets": {
+      "today": "Сегодня",
+      "yesterday": "Вчера",
+      "thisWeek": "Эта неделя",
+      "lastWeek": "Прошлая неделя",
+      "thisMonth": "Этот месяц",
+      "lastMonth": "Прошлый месяц"
+    },
+    "customRange": "Свой период",
+    "apply": "Применить",
+    "allTechnicians": "Все техники",
+    "export": "Экспорт CSV",
+    "addEntry": "Добавить запись",
+    "stats": {
+      "total": "Всего времени",
+      "totalHint": "{days, plural, =1 {1 день} one {# день} few {# дня} other {# дней}} в периоде",
+      "technicians": "Техники с учтённым временем",
+      "techniciansHint": "из {count} в команде",
+      "running": "Сейчас работают",
+      "runningHint": "в реальном времени",
+      "average": "В среднем за рабочий день",
+      "averageHint": "за {days, plural, =1 {1 день} one {# день} few {# дня} other {# дней}} с учтённым временем"
+    },
+    "nowStrip": {
+      "title": "Сейчас работают",
+      "since": "с {time}",
+      "stop": "Остановить"
+    },
+    "grid": {
+      "title": "Обзор",
+      "description": "Часы по техникам и дням. Нажмите на ячейку, чтобы перейти к этому дню.",
+      "technician": "Техник",
+      "total": "Итого",
+      "tooLong": "Обзор показывает до 31 дня. Сузьте период, чтобы его увидеть."
+    },
+    "table": {
+      "time": "Время",
+      "duration": "Длительность",
+      "job": "Заказ",
+      "vehicle": "Автомобиль",
+      "source": "Источник",
+      "note": "Заметка",
+      "running": "Идёт",
+      "dayTotal": "Итого за день",
+      "counterSale": "Продажа у стойки",
+      "edit": "Изменить",
+      "stop": "Остановить таймер",
+      "delete": "Удалить",
+      "actions": "Действия"
+    },
+    "empty": {
+      "title": "За этот период время не учтено",
+      "body": "Техники начинают учёт в приложении Torqvoice Technicians или в разделе «Мои активные заказы» на панели. Записи появляются здесь, как только запускается таймер.",
+      "noTechnicians": "В команде пока никто не назначен техником. Свяжите участников команды с техниками в разделе Настройки, Команда.",
+      "teamLink": "Открыть настройки команды"
+    },
+    "deleteTitle": "Удалить эту запись?",
+    "deleteBody": "{duration} по заказу {job} ({technician}) будет удалено из табеля. Это нельзя отменить.",
+    "deleteConfirm": "Удалить",
+    "deleted": "Запись удалена",
+    "stoppedByManager": "Таймер остановлен на {minutes}",
+    "csv": {
+      "technician": "Техник",
+      "date": "Дата",
+      "start": "Начало",
+      "end": "Конец",
+      "duration": "Длительность",
+      "hours": "Часы",
+      "job": "Заказ",
+      "vehicle": "Автомобиль",
+      "plate": "Номер",
+      "source": "Источник",
+      "editedBy": "Исправлено",
+      "note": "Заметка"
+    },
+    "unlinked": "Без аккаунта",
+    "unlinkedHint": "Техник только на доске, не связан с участником команды"
+  },
+  "dialog": {
+    "addTitle": "Добавить запись времени",
+    "addDescription": "Время, которое техник отработал, но не учёл. Запись помечается как введённая вручную.",
+    "editTitle": "Исправить запись времени",
+    "editDescription": "Техник и заказ у записи не меняются. Кто внёс изменение, фиксируется.",
+    "technician": "Техник",
+    "pickTechnician": "Выберите техника",
+    "job": "Заказ-наряд",
+    "pickJob": "Выберите заказ-наряд",
+    "searchJobs": "Поиск по названию или номеру",
+    "searching": "Поиск…",
+    "noJobs": "Заказ-наряды не найдены",
+    "start": "Начало",
+    "end": "Конец",
+    "stillRunning": "Ещё идёт",
+    "leaveEndEmpty": "Оставьте конец пустым, чтобы таймер продолжал идти.",
+    "endBeforeStart": "Конец должен быть позже начала.",
+    "duration": "Длительность: {duration}",
+    "timesInWorkshopZone": "Время указано по часам мастерской ({zone}).",
+    "note": "Заметка",
+    "notePlaceholder": "Необязательно. Почему это добавлено или изменено.",
+    "cancel": "Отмена",
+    "save": "Сохранить",
+    "add": "Добавить",
+    "added": "Запись добавлена",
+    "updated": "Запись обновлена",
+    "saveFailed": "Не удалось сохранить запись"
+  }
+}

+ 9 - 2
messages/tr/audit.json

@@ -164,7 +164,10 @@
     "settings_freezeInvoices": "Eski faturalar kilitlendi",
     "inspection_reminders_sent": "Muayene hatırlatmaları gönderildi",
     "inspection_reminders_send": "Muayene hatırlatmaları gönderildi",
-    "service_videoCall_send": "Görüntülü görüşme bağlantısı gönderildi"
+    "service_videoCall_send": "Görüntülü görüşme bağlantısı gönderildi",
+    "timeEntry_create": "Zaman kaydı eklendi",
+    "timeEntry_update": "Zaman kaydı düzeltildi",
+    "timeEntry_delete": "Zaman kaydı silindi"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -300,6 +303,10 @@
     "settings_setDocumentDesignRule": "{name} belge tasarımının ne zaman kullanılacağı değiştirildi",
     "settings_freezeInvoices": "Kilitlemeden önceki {count, plural, one {# faturayı} other {# faturayı}} mevcut tasarımla kilitledi",
     "inspection_reminders_sent": "{count} muayene hatırlatması gönderildi",
-    "service_videoCall_send": "Görüntülü görüşme bağlantısını {channels} ile gönderdi"
+    "service_videoCall_send": "Görüntülü görüşme bağlantısını {channels} ile gönderdi",
+    "timeEntry_create": "{technician} için \"{job}\" işine {minutes} dakika eklendi",
+    "timeEntry_update": "{technician} için \"{job}\" işindeki zaman kaydı {minutes} dakika olarak düzeltildi",
+    "timeEntry_stop": "{technician} için \"{job}\" işindeki sayaç {minutes} dakikada durduruldu",
+    "timeEntry_delete": "{id} zaman kaydı silindi"
   }
 }

+ 2 - 1
messages/tr/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "{qty} işe ekle",
     "statusReport": "Durum Raporu",
     "report": "Rapor",
-    "observation": "Gözlem"
+    "observation": "Gözlem",
+    "empty": "Şu anda size atanmış iş emri yok."
   },
   "recentActivity": "Son aktivite",
   "noRecentActivity": "Son aktivite yok",

+ 2 - 0
messages/tr/navigation.json

@@ -20,6 +20,7 @@
     "allParts": "Tüm Parçalar",
     "laborPresets": "İşçilik şablonları",
     "reports": "Raporlar",
+    "timesheets": "Zaman çizelgeleri",
     "workBoard": "İş Panosu",
     "presenter": "Sunum",
     "adminOverview": "Yönetici Genel Bakış",
@@ -102,6 +103,7 @@
     "inventory": "Envanter",
     "laborPresets": "İşçilik şablonları",
     "reports": "Raporlar",
+    "timesheets": "Zaman çizelgeleri",
     "settings": "Ayarlar",
     "superAdmin": "Super Admin",
     "adminPanel": "Yönetici Paneli",

+ 1 - 0
messages/tr/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Muayeneler",
     "tire_hotel": "Lastik oteli",
     "reports": "Raporlar",
+    "time_tracking": "Zaman takibi",
     "work_board": "İş panosu",
     "ai_assistant": "Yapay zekâ asistanı",
     "settings": "Ayarlar"

+ 21 - 2
messages/tr/reports.json

@@ -12,7 +12,9 @@
     "parts": "Parçalar",
     "jobAnalytics": "İş Analizi",
     "retention": "Müşteri Sadakati",
-    "vehicles": "Araçlar"
+    "vehicles": "Araçlar",
+    "techniciansOverview": "Genel bakış",
+    "clockedTime": "Kaydedilen süre"
   },
   "dateRange": {
     "to": "-",
@@ -138,6 +140,19 @@
       "avgRevenue": "Ort. Gelir",
       "totalHours": "Toplam Saat",
       "avgHours": "Ort. Saat"
+    },
+    "time": {
+      "clockedHours": "Kaydedilen saat",
+      "billedHours": "Faturalanan saat",
+      "efficiency": "Verimlilik",
+      "techniciansClocked": "Süresi olan teknisyenler",
+      "chartTitle": "Teknisyene göre kaydedilen ve faturalanan",
+      "tableTitle": "Kaydedilen ve faturalanan",
+      "clocked": "Kaydedilen",
+      "billed": "Faturalanan",
+      "hint": "Kaydedilen saatler zaman sayacından gelir. Faturalanan saatler, teknisyene atanan ve aralıkta başlayan işlerdeki işçilik satırlarıdır. Verimlilik, faturalananın kaydedilene bölümüdür.",
+      "noClock": "Süre yok",
+      "jobsClocked": "Kaydedilen işler"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Tür",
     "status": "Durum",
     "partsUsed": "Parçalar",
-    "laborHours": "İşçilik Saatleri"
+    "laborHours": "İşçilik Saatleri",
+    "clockedHours": "Kaydedilen saat",
+    "billedHours": "Faturalanan saat",
+    "efficiency": "Verimlilik",
+    "jobsClocked": "Kaydedilen işler"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 141 - 0
messages/tr/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Sayaç {job} işinde çalışıyor",
+    "stop": "Sayacı durdur",
+    "stopped": "{minutes} kaydedildi",
+    "switched": "Önceki iş {minutes} sürede durduruldu",
+    "startFailed": "Sayaç başlatılamadı",
+    "stopFailed": "Sayaç durdurulamadı"
+  },
+  "job": {
+    "title": "Kaydedilen süre",
+    "running": "çalışıyor",
+    "clockIn": "İşe başla",
+    "clockOut": "İşi bitir",
+    "switchHere": "Bu işe geç",
+    "switchHint": "{job} işindeki sayacı durdurur ve burada başlatır",
+    "addAsLabor": "{hours, number} sa işçilik olarak ekle",
+    "addAsLaborHint": "Varsayılan ücretle {hours, number} saatlik bir işçilik satırı ekler. Kaydedilen süre değişmez.",
+    "laborDescription": "İşçilik (kaydedilen süre)",
+    "empty": "Bu işte henüz süre kaydedilmedi. Teknisyenler uygulamadan veya panodaki Aktif İşlerim bölümünden işe başlar.",
+    "showAll": "{count} tane daha göster",
+    "showFewer": "Daha az göster",
+    "openTimesheets": "Zaman çizelgelerini aç"
+  },
+  "source": {
+    "app": "Uygulama",
+    "web": "Web",
+    "manual": "Elle",
+    "editedBy": "{name} tarafından düzeltildi"
+  },
+  "page": {
+    "title": "Zaman çizelgeleri",
+    "description": "Kimin ne zaman çalıştığı, atölyenin saatine göre.",
+    "loadFailed": "Zaman çizelgesi yüklenemedi",
+    "presets": {
+      "today": "Bugün",
+      "yesterday": "Dün",
+      "thisWeek": "Bu hafta",
+      "lastWeek": "Geçen hafta",
+      "thisMonth": "Bu ay",
+      "lastMonth": "Geçen ay"
+    },
+    "customRange": "Özel aralık",
+    "apply": "Uygula",
+    "allTechnicians": "Tüm teknisyenler",
+    "export": "CSV dışa aktar",
+    "addEntry": "Kayıt ekle",
+    "stats": {
+      "total": "Toplam süre",
+      "totalHint": "aralıkta {days, plural, =1 {1 gün} other {# gün}}",
+      "technicians": "Süresi olan teknisyenler",
+      "techniciansHint": "ekipteki {count} kişiden",
+      "running": "Şu an çalışanlar",
+      "runningHint": "canlı",
+      "average": "Aktif gün başına ortalama",
+      "averageHint": "süresi olan {days, plural, =1 {1 gün} other {# gün}} üzerinden"
+    },
+    "nowStrip": {
+      "title": "Şu an çalışanlar",
+      "since": "{time} itibarıyla",
+      "stop": "Durdur"
+    },
+    "grid": {
+      "title": "Genel bakış",
+      "description": "Teknisyen ve gün başına saatler. O güne gitmek için bir hücreye tıklayın.",
+      "technician": "Teknisyen",
+      "total": "Toplam",
+      "tooLong": "Genel bakış en fazla 31 gün gösterir. Görmek için aralığı daraltın."
+    },
+    "table": {
+      "time": "Saat",
+      "duration": "Süre",
+      "job": "İş",
+      "vehicle": "Araç",
+      "source": "Kaynak",
+      "note": "Not",
+      "running": "Çalışıyor",
+      "dayTotal": "Gün toplamı",
+      "counterSale": "Tezgâh satışı",
+      "edit": "Düzenle",
+      "stop": "Sayacı durdur",
+      "delete": "Sil",
+      "actions": "İşlemler"
+    },
+    "empty": {
+      "title": "Bu aralıkta kaydedilen süre yok",
+      "body": "Teknisyenler Torqvoice Technicians uygulamasından veya panodaki Aktif İşlerim bölümünden işe başlar. Bir sayaç başladığı anda kayıtlar burada görünür.",
+      "noTechnicians": "Ekipte henüz kimse teknisyen olarak ayarlanmadı. Ayarlar, Ekip altında ekip üyelerini teknisyenlerle eşleştirin.",
+      "teamLink": "Ekip ayarlarını aç"
+    },
+    "deleteTitle": "Bu kayıt silinsin mi?",
+    "deleteBody": "{technician} tarafından {job} işindeki {duration} zaman çizelgesinden kaldırılacak. Bu geri alınamaz.",
+    "deleteConfirm": "Sil",
+    "deleted": "Kayıt silindi",
+    "stoppedByManager": "Sayaç {minutes} sürede durduruldu",
+    "csv": {
+      "technician": "Teknisyen",
+      "date": "Tarih",
+      "start": "Başlangıç",
+      "end": "Bitiş",
+      "duration": "Süre",
+      "hours": "Saat",
+      "job": "İş",
+      "vehicle": "Araç",
+      "plate": "Plaka",
+      "source": "Kaynak",
+      "editedBy": "Düzelten",
+      "note": "Not"
+    },
+    "unlinked": "Hesap yok",
+    "unlinkedHint": "Yalnızca panoda olan, bir ekip üyesine bağlı olmayan teknisyen"
+  },
+  "dialog": {
+    "addTitle": "Zaman kaydı ekle",
+    "addDescription": "Bir teknisyenin çalıştığı ama kaydetmediği süre. Elle girildi olarak işaretlenir.",
+    "editTitle": "Zaman kaydını düzelt",
+    "editDescription": "Kaydın teknisyeni ve işi değişmez. Kimin değiştirdiği kaydedilir.",
+    "technician": "Teknisyen",
+    "pickTechnician": "Bir teknisyen seçin",
+    "job": "İş emri",
+    "pickJob": "Bir iş emri seçin",
+    "searchJobs": "Başlık veya plakaya göre ara",
+    "searching": "Aranıyor…",
+    "noJobs": "İş emri bulunamadı",
+    "start": "Başlangıç",
+    "end": "Bitiş",
+    "stillRunning": "Hâlâ çalışıyor",
+    "leaveEndEmpty": "Sayacın çalışmaya devam etmesi için bitişi boş bırakın.",
+    "endBeforeStart": "Bitiş, başlangıçtan sonra olmalı.",
+    "duration": "Süre: {duration}",
+    "timesInWorkshopZone": "Saatler atölyenin saatine göredir ({zone}).",
+    "note": "Not",
+    "notePlaceholder": "İsteğe bağlı. Neden eklendiği veya değiştirildiği.",
+    "cancel": "İptal",
+    "save": "Kaydet",
+    "add": "Ekle",
+    "added": "Kayıt eklendi",
+    "updated": "Kayıt güncellendi",
+    "saveFailed": "Kayıt kaydedilemedi"
+  }
+}

+ 179 - 0
src/__tests__/features/time-tracking/timesheet.test.ts

@@ -0,0 +1,179 @@
+import { describe, expect, it } from 'vitest'
+import {
+  buildTimesheet,
+  dayKeysBetween,
+  entryMinutes,
+  formatElapsed,
+  formatMinutes,
+  minutesToBillableHours,
+  sliceByDay,
+  type SheetEntry,
+} from '@/features/time-tracking/Lib/timesheet'
+
+const TZ = 'Europe/Oslo'
+
+function entry(overrides: Partial<SheetEntry> & { startedAt: string; endedAt: string | null }) {
+  const durationMinutes = overrides.endedAt
+    ? Math.round(
+        (new Date(overrides.endedAt).getTime() - new Date(overrides.startedAt).getTime()) / 60_000
+      )
+    : null
+  return {
+    id: 'e1',
+    durationMinutes,
+    note: null,
+    source: 'app',
+    editedAt: null,
+    editedByName: null,
+    technicianId: 't1',
+    technicianName: 'Kari',
+    technicianColor: '#3b82f6',
+    job: {
+      id: 'j1',
+      title: 'Brakes',
+      status: 'in-progress',
+      vehicleId: 'v1',
+      vehicleLabel: 'Volvo V70',
+      licensePlate: 'AB12345',
+    },
+    ...overrides,
+  } satisfies SheetEntry
+}
+
+describe('sliceByDay', () => {
+  it('keeps an ordinary stretch on one workshop day', () => {
+    // 08:00 to 11:30 Oslo summer time is 06:00Z to 09:30Z.
+    const e = entry({ startedAt: '2026-09-07T06:00:00Z', endedAt: '2026-09-07T09:30:00Z' })
+    expect(sliceByDay(e, TZ, new Date('2026-09-08T00:00:00Z'))).toEqual([
+      { dayKey: '2026-09-07', minutes: 210 },
+    ])
+  })
+
+  it('splits a night shift at the workshop midnight, not UTC midnight', () => {
+    // 22:00 Oslo (20:00Z) to 02:00 Oslo next day (00:00Z).
+    const e = entry({ startedAt: '2026-09-07T20:00:00Z', endedAt: '2026-09-08T00:00:00Z' })
+    expect(sliceByDay(e, TZ, new Date('2026-09-09T00:00:00Z'))).toEqual([
+      { dayKey: '2026-09-07', minutes: 120 },
+      { dayKey: '2026-09-08', minutes: 120 },
+    ])
+  })
+
+  it('counts a running clock up to now', () => {
+    const e = entry({ startedAt: '2026-09-07T06:00:00Z', endedAt: null })
+    expect(sliceByDay(e, TZ, new Date('2026-09-07T06:45:00Z'))).toEqual([
+      { dayKey: '2026-09-07', minutes: 45 },
+    ])
+  })
+
+  it('clips to the requested window', () => {
+    const e = entry({ startedAt: '2026-09-06T20:00:00Z', endedAt: '2026-09-07T02:00:00Z' })
+    const window = { from: new Date('2026-09-06T22:00:00Z'), to: new Date('2026-09-08T22:00:00Z') }
+    expect(sliceByDay(e, TZ, new Date('2026-09-09T00:00:00Z'), window)).toEqual([
+      { dayKey: '2026-09-07', minutes: 240 },
+    ])
+  })
+
+  it('returns nothing for a stretch entirely outside the window', () => {
+    const e = entry({ startedAt: '2026-09-01T06:00:00Z', endedAt: '2026-09-01T07:00:00Z' })
+    const window = { from: new Date('2026-09-06T22:00:00Z'), to: new Date('2026-09-08T22:00:00Z') }
+    expect(sliceByDay(e, TZ, new Date(), window)).toEqual([])
+  })
+})
+
+describe('dayKeysBetween', () => {
+  it('lists every workshop day inclusive', () => {
+    const from = new Date('2026-09-06T22:00:00Z') // Sep 7 00:00 Oslo
+    const to = new Date('2026-09-09T21:59:59Z') // Sep 9 23:59 Oslo
+    expect(dayKeysBetween(from, to, TZ)).toEqual(['2026-09-07', '2026-09-08', '2026-09-09'])
+  })
+})
+
+describe('buildTimesheet', () => {
+  const from = new Date('2026-09-06T22:00:00Z')
+  const to = new Date('2026-09-09T22:00:00Z')
+  const now = new Date('2026-09-08T10:00:00Z')
+
+  it('totals per technician and per day, most time first', () => {
+    const sheet = buildTimesheet({
+      entries: [
+        entry({ id: 'a', startedAt: '2026-09-07T06:00:00Z', endedAt: '2026-09-07T08:00:00Z' }),
+        entry({
+          id: 'b',
+          technicianId: 't2',
+          technicianName: 'Ola',
+          startedAt: '2026-09-07T06:00:00Z',
+          endedAt: '2026-09-07T13:00:00Z',
+        }),
+        entry({ id: 'c', startedAt: '2026-09-08T06:00:00Z', endedAt: null }),
+      ],
+      technicians: [
+        { id: 't1', name: 'Kari', color: '#000' },
+        { id: 't2', name: 'Ola', color: '#111' },
+        { id: 't3', name: 'Idle', color: '#222' },
+      ],
+      from,
+      to,
+      timeZone: TZ,
+      now,
+    })
+
+    expect(sheet.technicians.map((s) => s.technician.id)).toEqual(['t2', 't1', 't3'])
+    const kari = sheet.technicians[1]
+    expect(kari.totalMinutes).toBe(120 + 240)
+    expect(kari.byDay.get('2026-09-07')).toBe(120)
+    expect(kari.byDay.get('2026-09-08')).toBe(240)
+    expect(kari.running?.id).toBe('c')
+    expect(sheet.technicians[2].totalMinutes).toBe(0)
+    expect(sheet.dayTotals.get('2026-09-07')).toBe(120 + 420)
+    expect(sheet.totalMinutes).toBe(120 + 420 + 240)
+    expect(sheet.runningCount).toBe(1)
+    expect(sheet.dayKeys).toEqual(['2026-09-07', '2026-09-08', '2026-09-09'])
+  })
+
+  it('names a technician who is no longer on the roster from the entry', () => {
+    const sheet = buildTimesheet({
+      entries: [
+        entry({
+          technicianId: 'gone',
+          technicianName: 'Former',
+          startedAt: '2026-09-07T06:00:00Z',
+          endedAt: '2026-09-07T07:00:00Z',
+        }),
+      ],
+      technicians: [],
+      from,
+      to,
+      timeZone: TZ,
+      now,
+    })
+    expect(sheet.technicians[0].technician.name).toBe('Former')
+  })
+})
+
+describe('formatting', () => {
+  it('formats minutes as hours and minutes', () => {
+    expect(formatMinutes(0)).toBe('0m')
+    expect(formatMinutes(45)).toBe('45m')
+    expect(formatMinutes(65)).toBe('1h 05m')
+    expect(formatMinutes(600)).toBe('10h 00m')
+  })
+
+  it('formats a running clock with seconds', () => {
+    expect(formatElapsed(0)).toBe('0:00:00')
+    expect(formatElapsed(3_661_000)).toBe('1:01:01')
+  })
+
+  it('rounds clocked time to a billable quarter hour, never below one', () => {
+    expect(minutesToBillableHours(0)).toBe(0)
+    expect(minutesToBillableHours(3)).toBe(0.25)
+    expect(minutesToBillableHours(37)).toBe(0.5)
+    expect(minutesToBillableHours(83)).toBe(1.5)
+  })
+
+  it('uses the stored duration for a stopped entry and live time for a running one', () => {
+    const stopped = entry({ startedAt: '2026-09-07T06:00:00Z', endedAt: '2026-09-07T07:00:00Z' })
+    expect(entryMinutes(stopped, new Date('2026-09-07T09:00:00Z'))).toBe(60)
+    const running = entry({ startedAt: '2026-09-07T06:00:00Z', endedAt: null })
+    expect(entryMinutes(running, new Date('2026-09-07T06:20:00Z'))).toBe(20)
+  })
+})

+ 3 - 0
src/__tests__/i18n/audit-summaries.test.ts

@@ -124,6 +124,9 @@ describe('audit summaries', () => {
       count: 2,
       records: 2,
       quotes: 2,
+      technician: 'Kari',
+      job: 'Brakes',
+      minutes: 45,
     }
     for (const locale of LOCALES) {
       const messages = summary(locale)

+ 33 - 21
src/app/(authenticated)/layout.tsx

@@ -35,6 +35,8 @@ import { PlateLookupCommand } from '@/features/vehicles/Components/PlateLookupCo
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
 import { countUnreadMessages } from '@/features/messaging/Lib/unreadCount'
 import { addZonedDays, safeTimeZone, startOfZonedDay } from '@/lib/timezone'
+import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
+import { TimeClockProvider } from '@/features/time-tracking/Components/TimeClockProvider'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -175,6 +177,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
     seen: seenHints,
   })
 
+  // Whether this account can clock in from the browser: only when somebody
+  // has linked it to a technician row. Resolved here so the header pill and
+  // the work order buttons know on first paint.
+  const technicianIds = await technicianIdsForUser(data.organizationId, data.userId)
+
   // The header offers a plate lookup once a vehicle registry is connected.
   // Resolved here so the first paint knows, rather than a button appearing a
   // beat after the page does.
@@ -264,31 +271,36 @@ export default async function DashboardLayout({ children }: { children: React.Re
                       initialSeen={seenHints}
                       pending={[...pendingHints, ...announcements]}
                     >
-                      <AppSidebar
-                        companyLogo={data.companyLogo}
-                        organizations={data.organizations}
-                        activeOrgId={data.organizationId}
-                        isSuperAdmin={data.isSuperAdmin}
-                        features={features}
-                        tireHotelEnabled={tireHotelEnabled}
-                        visibleSubjects={visibleSubjects}
-                        announcement={announcements[0] ?? null}
-                        isAdminOrOwner={isOwnerOrAdmin}
-                        counts={sidebarCounts}
-                      />
-                      <SidebarInset>
-                        {/* A flex column with a real height, so the `flex-1` every
+                      <TimeClockProvider technicianIds={technicianIds}>
+                        <AppSidebar
+                          companyLogo={data.companyLogo}
+                          organizations={data.organizations}
+                          activeOrgId={data.organizationId}
+                          isSuperAdmin={data.isSuperAdmin}
+                          features={features}
+                          tireHotelEnabled={tireHotelEnabled}
+                          visibleSubjects={visibleSubjects}
+                          announcement={announcements[0] ?? null}
+                          isAdminOrOwner={isOwnerOrAdmin}
+                          counts={sidebarCounts}
+                          isTechnician={technicianIds.length > 0}
+                        />
+                        <SidebarInset>
+                          {/* 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 />
-                      <PlateLookupCommand />
-                      {isOwnerOrAdmin && <NotificationInitializer />}
-                      <OnlineTracker />
-                      <InstallBanner />
+                          <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
+                            {children}
+                          </div>
+                        </SidebarInset>
+                        <SearchCommand />
+                        <PlateLookupCommand />
+                        {isOwnerOrAdmin && <NotificationInitializer />}
+                        <OnlineTracker />
+                        <InstallBanner />
+                      </TimeClockProvider>
                     </FeatureHintProvider>
                   </PlateLookupProvider>
                 </ConfirmProvider>

+ 22 - 0
src/app/(authenticated)/page.tsx

@@ -25,6 +25,7 @@ import { DashboardClient } from './dashboard-client'
 import { db } from '@/lib/db'
 import { sanitizeConfig, type CustomWidget } from '@/features/dashboard/custom-cards/registry'
 import { MyActiveJobs } from '@/features/vehicles/Components/MyActiveJobs'
+import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
 import { PageHeader } from '@/components/page-header'
 import { getTireHotelSummary } from '@/features/tire-hotel/Actions/getTireHotelSummary'
 import { getInspectionsDueSummary } from '@/features/vehicles/Actions/inspectionStatusActions'
@@ -103,6 +104,27 @@ export default async function DashboardPage() {
 
   if (!result.success || !result.data) {
     const t = await getTranslations('dashboard')
+    // A technician's role often grants services and inventory but not the
+    // dashboard figures. Their jobs, and the clock on them, must not vanish
+    // with the revenue tiles: the page becomes their job list instead.
+    const technicianIds = auth ? await technicianIdsForUser(auth.organizationId, auth.userId) : []
+    if (technicianIds.length > 0) {
+      const myJobs = myJobsResult.success && myJobsResult.data ? myJobsResult.data : []
+      return (
+        <>
+          <PageHeader />
+          <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+            <MyActiveJobs
+              jobs={myJobs}
+              smsEnabled={features?.sms ?? false}
+              emailEnabled={features?.smtp ?? false}
+              telegramEnabled={features?.telegram ?? false}
+              showEmpty
+            />
+          </div>
+        </>
+      )
+    }
     return (
       <>
         <PageHeader />

+ 315 - 88
src/app/(authenticated)/reports/reports-client.tsx

@@ -54,6 +54,8 @@ import {
   ChevronLeft,
   ChevronRight,
   RefreshCw,
+  Timer,
+  Gauge,
 } from 'lucide-react'
 import {
   getRevenueReport,
@@ -67,6 +69,7 @@ import {
   getTaxReport,
   getPastDueInvoicesReport,
   getVehicleReport,
+  getTechnicianTimeReport,
 } from '@/features/reports/Actions/reportActions'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import type {
@@ -81,11 +84,15 @@ import type {
   TaxReport,
   PastDueInvoicesReport,
   VehicleReportData,
+  TechnicianTimeReport,
 } from '@/features/reports/Schema/reportTypes'
 import { RevenueBarChart, RevenueTypeDonut } from '@/features/reports/Components/RevenueCharts'
 import { ServiceStatusChart, ServiceTypeDonut } from '@/features/reports/Components/ServiceCharts'
 import { TopCustomersChart } from '@/features/reports/Components/CustomerCharts'
-import { TechnicianBarChart } from '@/features/reports/Components/TechnicianCharts'
+import {
+  TechnicianBarChart,
+  TechnicianTimeChart,
+} from '@/features/reports/Components/TechnicianCharts'
 import { PartsDonut } from '@/features/reports/Components/PartsCharts'
 import {
   DayOfWeekChart,
@@ -108,6 +115,7 @@ import {
   exportTaxCsv,
   exportPastDueInvoicesCsv,
   exportVehicleReportCsv,
+  exportTechnicianTimeCsv,
 } from '@/features/reports/Components/csv-export'
 import { ReportPDF } from '@/features/reports/Components/ReportPDF'
 import { pdf } from '@react-pdf/renderer'
@@ -123,6 +131,7 @@ type ReportTab =
   | 'retention'
   | 'vehicles'
 type FinancialSubTab = 'revenue' | 'past-due-invoices' | 'tax'
+type TechnicianSubTab = 'overview' | 'clocked-time'
 type PastDueSortKey = 'customerName' | 'amountDue' | 'daysPastDue'
 type PastDueSortDir = 'asc' | 'desc'
 
@@ -144,6 +153,7 @@ const VALID_TABS: ReportTab[] = [
   'vehicles',
 ]
 const VALID_SUB_TABS: FinancialSubTab[] = ['revenue', 'past-due-invoices', 'tax']
+const VALID_TECH_SUB_TABS: TechnicianSubTab[] = ['overview', 'clocked-time']
 
 export default function ReportsClient({
   currencyCode,
@@ -170,6 +180,13 @@ export default function ReportsClient({
 
   const [activeTab, setActiveTab] = useState<ReportTab>(initialTab)
   const [financialSubTab, setFinancialSubTab] = useState<FinancialSubTab>(initialSubTab)
+  const initialTechSubTab = (
+    VALID_TECH_SUB_TABS.includes(searchParams.get('subtab') as TechnicianSubTab)
+      ? searchParams.get('subtab')
+      : 'overview'
+  ) as TechnicianSubTab
+  const [technicianSubTab, setTechnicianSubTab] = useState<TechnicianSubTab>(initialTechSubTab)
+  const [technicianTimeData, setTechnicianTimeData] = useState<TechnicianTimeReport | null>(null)
 
   const updateUrl = useCallback(
     (tab: string, subtab?: string) => {
@@ -216,7 +233,10 @@ export default function ReportsClient({
     [currencyCode]
   )
 
-  type FetchableReport = ReportTab | FinancialSubTab
+  type FetchableReport = ReportTab | FinancialSubTab | 'clocked-time'
+  // The technicians tab fetches one of two reports depending on its sub-tab.
+  const technicianFetchKey = (sub: TechnicianSubTab): FetchableReport =>
+    sub === 'clocked-time' ? 'clocked-time' : 'technicians'
 
   const fetchReport = useCallback(
     async (type: FetchableReport, overrideDateRange?: DateRange, overrideVehicleId?: string) => {
@@ -263,6 +283,11 @@ export default function ReportsClient({
             if (result.success && result.data) setTechnicianData(result.data)
             break
           }
+          case 'clocked-time': {
+            const result = await getTechnicianTimeReport(dateParams)
+            if (result.success && result.data) setTechnicianTimeData(result.data)
+            break
+          }
           case 'parts': {
             const result = await getPartsUsageReport(dateParams)
             if (result.success && result.data) setPartsData(result.data)
@@ -299,6 +324,8 @@ export default function ReportsClient({
   useEffect(() => {
     if (activeTab === 'financial') {
       fetchReport(financialSubTab)
+    } else if (activeTab === 'technicians') {
+      fetchReport(technicianFetchKey(technicianSubTab))
     } else {
       fetchReport(activeTab)
     }
@@ -307,7 +334,10 @@ export default function ReportsClient({
   const handleTabChange = (value: string) => {
     const tab = value as ReportTab
     setActiveTab(tab)
-    updateUrl(tab, tab === 'financial' ? financialSubTab : undefined)
+    updateUrl(
+      tab,
+      tab === 'financial' ? financialSubTab : tab === 'technicians' ? technicianSubTab : undefined
+    )
     if (tab === 'financial') {
       const subDataMap: Record<FinancialSubTab, unknown> = {
         revenue: revenueData,
@@ -317,6 +347,9 @@ export default function ReportsClient({
       if (!subDataMap[financialSubTab]) {
         fetchReport(financialSubTab)
       }
+    } else if (tab === 'technicians') {
+      const has = technicianSubTab === 'clocked-time' ? technicianTimeData : technicianData
+      if (!has) fetchReport(technicianFetchKey(technicianSubTab))
     } else {
       const dataMap: Record<string, unknown> = {
         services: serviceData,
@@ -352,9 +385,19 @@ export default function ReportsClient({
     }
   }
 
+  const handleTechnicianSubTabChange = (value: string) => {
+    const subTab = value as TechnicianSubTab
+    setTechnicianSubTab(subTab)
+    updateUrl('technicians', subTab)
+    const has = subTab === 'clocked-time' ? technicianTimeData : technicianData
+    if (!has) fetchReport(technicianFetchKey(subTab))
+  }
+
   const handleRefresh = () => {
     if (activeTab === 'financial') {
       fetchReport(financialSubTab)
+    } else if (activeTab === 'technicians') {
+      fetchReport(technicianFetchKey(technicianSubTab))
     } else if (activeTab === 'vehicles') {
       if (selectedVehicleId) fetchReport('vehicles')
     } else {
@@ -427,6 +470,17 @@ export default function ReportsClient({
             ])
           break
         case 'technicians':
+          if (technicianSubTab === 'clocked-time') {
+            if (technicianTimeData)
+              exportTechnicianTimeCsv(technicianTimeData, [
+                h('technician'),
+                h('clockedHours'),
+                h('billedHours'),
+                h('efficiency'),
+                h('jobsClocked'),
+              ])
+            break
+          }
           if (technicianData)
             exportTechniciansCsv(technicianData, currencyCode, [
               h('technician'),
@@ -666,7 +720,7 @@ export default function ReportsClient({
       services: serviceData,
       customers: customerData,
       inventory: inventoryData,
-      technicians: technicianData,
+      technicians: technicianSubTab === 'clocked-time' ? technicianTimeData : technicianData,
       parts: partsData,
       'job-analytics': jobAnalyticsData,
       retention: retentionData,
@@ -880,6 +934,8 @@ export default function ReportsClient({
                             setDatePickerOpen(false)
                             if (activeTab === 'financial') {
                               fetchReport(financialSubTab, range)
+                            } else if (activeTab === 'technicians') {
+                              fetchReport(technicianFetchKey(technicianSubTab), range)
                             } else {
                               fetchReport(activeTab, range)
                             }
@@ -900,6 +956,8 @@ export default function ReportsClient({
                       setDatePickerOpen(false)
                       if (activeTab === 'financial') {
                         fetchReport(financialSubTab, pendingDateRange)
+                      } else if (activeTab === 'technicians') {
+                        fetchReport(technicianFetchKey(technicianSubTab), pendingDateRange)
                       } else {
                         fetchReport(activeTab, pendingDateRange)
                       }
@@ -1723,92 +1781,261 @@ export default function ReportsClient({
 
       {/* Technicians Tab */}
       <TabsContent value="technicians">
-        {!loading && technicianData && (
-          <div className="space-y-4">
-            <div className="grid gap-3 grid-cols-2 max-w-lg">
-              <Card>
-                <CardContent className="flex items-center gap-3 p-4">
-                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
-                    <Wrench className="h-4 w-4 text-violet-500" />
-                  </div>
-                  <div className="min-w-0">
-                    <p className="text-xs text-muted-foreground">{t('technicians.totalJobs')}</p>
-                    <p className="text-lg font-semibold">{technicianData.totalJobs}</p>
-                  </div>
-                </CardContent>
-              </Card>
-              <Card>
-                <CardContent className="flex items-center gap-3 p-4">
-                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
-                    <DollarSign className="h-4 w-4 text-emerald-500" />
-                  </div>
-                  <div className="min-w-0">
-                    <p className="text-xs text-muted-foreground">{t('technicians.totalRevenue')}</p>
-                    <p className="text-lg font-semibold truncate">
-                      {fmtCurrency(technicianData.totalRevenue)}
-                    </p>
-                  </div>
-                </CardContent>
-              </Card>
-            </div>
-            {technicianData.technicians.length > 0 && (
-              <AppCard title={t('technicians.revenueByTechnician')}>
-                <TechnicianBarChart
-                  data={technicianData.technicians}
-                  formatCurrency={fmtCurrency}
-                  labels={{ revenue: t('charts.revenue') }}
-                />
-              </AppCard>
+        <Tabs
+          value={technicianSubTab}
+          onValueChange={handleTechnicianSubTabChange}
+          className="space-y-4"
+        >
+          <TabsList variant="line">
+            <TabsTrigger value="overview" className="gap-1.5">
+              <UserCheck className="h-4 w-4" />
+              {t('tabs.techniciansOverview')}
+            </TabsTrigger>
+            <TabsTrigger value="clocked-time" className="gap-1.5">
+              <Timer className="h-4 w-4" />
+              {t('tabs.clockedTime')}
+            </TabsTrigger>
+          </TabsList>
+
+          <TabsContent value="overview">
+            {!loading && technicianData && (
+              <div className="space-y-4">
+                <div className="grid gap-3 grid-cols-2 max-w-lg">
+                  <Card>
+                    <CardContent className="flex items-center gap-3 p-4">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
+                        <Wrench className="h-4 w-4 text-violet-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="text-xs text-muted-foreground">
+                          {t('technicians.totalJobs')}
+                        </p>
+                        <p className="text-lg font-semibold">{technicianData.totalJobs}</p>
+                      </div>
+                    </CardContent>
+                  </Card>
+                  <Card>
+                    <CardContent className="flex items-center gap-3 p-4">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
+                        <DollarSign className="h-4 w-4 text-emerald-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="text-xs text-muted-foreground">
+                          {t('technicians.totalRevenue')}
+                        </p>
+                        <p className="text-lg font-semibold truncate">
+                          {fmtCurrency(technicianData.totalRevenue)}
+                        </p>
+                      </div>
+                    </CardContent>
+                  </Card>
+                </div>
+                {technicianData.technicians.length > 0 && (
+                  <AppCard title={t('technicians.revenueByTechnician')}>
+                    <TechnicianBarChart
+                      data={technicianData.technicians}
+                      formatCurrency={fmtCurrency}
+                      labels={{ revenue: t('charts.revenue') }}
+                    />
+                  </AppCard>
+                )}
+                {technicianData.technicians.length > 0 && (
+                  <AppCard title={t('technicians.technicianBreakdown')}>
+                    <Table>
+                      <TableHeader>
+                        <TableRow>
+                          <TableHead>{t('technicians.tableHeaders.technician')}</TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.tableHeaders.jobs')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.tableHeaders.revenue')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.tableHeaders.avgRevenue')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.tableHeaders.totalHours')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.tableHeaders.avgHours')}
+                          </TableHead>
+                        </TableRow>
+                      </TableHeader>
+                      <TableBody>
+                        {technicianData.technicians.map((row) => (
+                          <TableRow key={row.techName}>
+                            <TableCell className="text-sm font-medium">{row.techName}</TableCell>
+                            <TableCell className="text-right text-sm">{row.jobCount}</TableCell>
+                            <TableCell className="text-right text-sm">
+                              {fmtCurrency(row.totalRevenue)}
+                            </TableCell>
+                            <TableCell className="text-right text-sm">
+                              {fmtCurrency(row.avgRevenue)}
+                            </TableCell>
+                            <TableCell className="text-right text-sm">
+                              {row.totalLaborHours.toFixed(1)}
+                            </TableCell>
+                            <TableCell className="text-right text-sm">
+                              {row.avgHours.toFixed(1)}
+                            </TableCell>
+                          </TableRow>
+                        ))}
+                      </TableBody>
+                    </Table>
+                  </AppCard>
+                )}
+              </div>
             )}
-            {technicianData.technicians.length > 0 && (
-              <AppCard title={t('technicians.technicianBreakdown')}>
-                <Table>
-                  <TableHeader>
-                    <TableRow>
-                      <TableHead>{t('technicians.tableHeaders.technician')}</TableHead>
-                      <TableHead className="text-right">
-                        {t('technicians.tableHeaders.jobs')}
-                      </TableHead>
-                      <TableHead className="text-right">
-                        {t('technicians.tableHeaders.revenue')}
-                      </TableHead>
-                      <TableHead className="text-right">
-                        {t('technicians.tableHeaders.avgRevenue')}
-                      </TableHead>
-                      <TableHead className="text-right">
-                        {t('technicians.tableHeaders.totalHours')}
-                      </TableHead>
-                      <TableHead className="text-right">
-                        {t('technicians.tableHeaders.avgHours')}
-                      </TableHead>
-                    </TableRow>
-                  </TableHeader>
-                  <TableBody>
-                    {technicianData.technicians.map((row) => (
-                      <TableRow key={row.techName}>
-                        <TableCell className="text-sm font-medium">{row.techName}</TableCell>
-                        <TableCell className="text-right text-sm">{row.jobCount}</TableCell>
-                        <TableCell className="text-right text-sm">
-                          {fmtCurrency(row.totalRevenue)}
-                        </TableCell>
-                        <TableCell className="text-right text-sm">
-                          {fmtCurrency(row.avgRevenue)}
-                        </TableCell>
-                        <TableCell className="text-right text-sm">
-                          {row.totalLaborHours.toFixed(1)}
-                        </TableCell>
-                        <TableCell className="text-right text-sm">
-                          {row.avgHours.toFixed(1)}
-                        </TableCell>
-                      </TableRow>
-                    ))}
-                  </TableBody>
-                </Table>
-              </AppCard>
+            {!loading && !technicianData && <EmptyState message={t('empty')} />}
+          </TabsContent>
+
+          {/* Clocked time sub-tab: what technicians spent against what was billed */}
+          <TabsContent value="clocked-time">
+            {!loading && technicianTimeData && (
+              <div className="space-y-4">
+                <div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
+                  <Card>
+                    <CardContent className="flex items-center gap-3 p-4">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10">
+                        <Timer className="h-4 w-4 text-primary" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="text-xs text-muted-foreground">
+                          {t('technicians.time.clockedHours')}
+                        </p>
+                        <p className="text-lg font-semibold">
+                          {(technicianTimeData.totalClockedMinutes / 60).toFixed(1)}
+                        </p>
+                      </div>
+                    </CardContent>
+                  </Card>
+                  <Card>
+                    <CardContent className="flex items-center gap-3 p-4">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
+                        <Wrench className="h-4 w-4 text-violet-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="text-xs text-muted-foreground">
+                          {t('technicians.time.billedHours')}
+                        </p>
+                        <p className="text-lg font-semibold">
+                          {technicianTimeData.totalBilledHours.toFixed(1)}
+                        </p>
+                      </div>
+                    </CardContent>
+                  </Card>
+                  <Card>
+                    <CardContent className="flex items-center gap-3 p-4">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
+                        <Gauge className="h-4 w-4 text-blue-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="text-xs text-muted-foreground">
+                          {t('technicians.time.efficiency')}
+                        </p>
+                        <p className="text-lg font-semibold">
+                          {technicianTimeData.efficiency === null
+                            ? '–'
+                            : `${technicianTimeData.efficiency.toFixed(0)}%`}
+                        </p>
+                      </div>
+                    </CardContent>
+                  </Card>
+                  <Card>
+                    <CardContent className="flex items-center gap-3 p-4">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-amber-500/10">
+                        <Users className="h-4 w-4 text-amber-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="text-xs text-muted-foreground">
+                          {t('technicians.time.techniciansClocked')}
+                        </p>
+                        <p className="text-lg font-semibold">
+                          {
+                            technicianTimeData.technicians.filter((r) => r.clockedMinutes > 0)
+                              .length
+                          }
+                        </p>
+                      </div>
+                    </CardContent>
+                  </Card>
+                </div>
+                {technicianTimeData.technicians.length > 0 && (
+                  <AppCard title={t('technicians.time.chartTitle')}>
+                    <TechnicianTimeChart
+                      data={technicianTimeData.technicians}
+                      labels={{
+                        clocked: t('technicians.time.clocked'),
+                        billed: t('technicians.time.billed'),
+                      }}
+                    />
+                  </AppCard>
+                )}
+                {technicianTimeData.technicians.length > 0 && (
+                  <AppCard
+                    title={t('technicians.time.tableTitle')}
+                    description={t('technicians.time.hint')}
+                  >
+                    <Table>
+                      <TableHeader>
+                        <TableRow>
+                          <TableHead>{t('technicians.tableHeaders.technician')}</TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.time.clocked')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.time.billed')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.time.efficiency')}
+                          </TableHead>
+                          <TableHead className="text-right">
+                            {t('technicians.time.jobsClocked')}
+                          </TableHead>
+                        </TableRow>
+                      </TableHeader>
+                      <TableBody>
+                        {technicianTimeData.technicians.map((row) => (
+                          <TableRow key={row.technicianId}>
+                            <TableCell className="text-sm font-medium">
+                              <span className="flex items-center gap-2">
+                                <span
+                                  className="size-2 rounded-full"
+                                  style={{ backgroundColor: row.color }}
+                                />
+                                {row.techName}
+                              </span>
+                            </TableCell>
+                            <TableCell className="text-right text-sm tabular-nums">
+                              {(row.clockedMinutes / 60).toFixed(1)}
+                            </TableCell>
+                            <TableCell className="text-right text-sm tabular-nums">
+                              {row.billedHours.toFixed(1)}
+                            </TableCell>
+                            <TableCell className="text-right text-sm tabular-nums">
+                              {row.efficiency === null ? (
+                                <span className="text-muted-foreground">
+                                  {t('technicians.time.noClock')}
+                                </span>
+                              ) : (
+                                `${row.efficiency.toFixed(0)}%`
+                              )}
+                            </TableCell>
+                            <TableCell className="text-right text-sm tabular-nums">
+                              {row.jobsClocked}
+                            </TableCell>
+                          </TableRow>
+                        ))}
+                      </TableBody>
+                    </Table>
+                  </AppCard>
+                )}
+              </div>
             )}
-          </div>
-        )}
-        {!loading && !technicianData && <EmptyState message={t('empty')} />}
+            {!loading && !technicianTimeData && <EmptyState message={t('empty')} />}
+          </TabsContent>
+        </Tabs>
       </TabsContent>
 
       {/* Parts Tab */}

+ 55 - 0
src/app/(authenticated)/timesheets/page.tsx

@@ -0,0 +1,55 @@
+import { redirect } from 'next/navigation'
+import { PageHeader } from '@/components/page-header'
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getCachedMembership } from '@/lib/cached-session'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { getTimesheet } from '@/features/time-tracking/Actions/timesheetActions'
+import TimesheetsClient from './timesheets-client'
+
+/**
+ * Who worked when: every technician's clocked time, by day, for a window.
+ *
+ * Gated by the time tracking subject rather than reports, so a foreman can
+ * be given this page without the revenue figures that sit beside it.
+ */
+export default async function TimesheetsPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ from?: string; to?: string; tech?: string }>
+}) {
+  const data = await getLayoutData()
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
+
+  const isOwnerOrAdmin =
+    data.role === 'owner' || data.role === 'admin' || data.role === 'super_admin'
+  if (!isOwnerOrAdmin) {
+    const membership = await getCachedMembership(data.userId)
+    if (membership?.roleId) {
+      const allowed = hasPermission(membership.customRole?.permissions ?? [], {
+        action: PermissionAction.READ,
+        subject: PermissionSubject.TIME_TRACKING,
+      })
+      if (!allowed) redirect('/')
+    }
+  }
+
+  const params = await searchParams
+  const initial = await getTimesheet({
+    from: params.from,
+    to: params.to,
+    technicianId: params.tech || null,
+  })
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <TimesheetsClient
+          initial={initial.success && initial.data ? initial.data : null}
+          initialError={initial.success ? null : (initial.error ?? null)}
+        />
+      </div>
+    </>
+  )
+}

+ 1070 - 0
src/app/(authenticated)/timesheets/timesheets-client.tsx

@@ -0,0 +1,1070 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { useFormatter, useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import type { DateRange } from 'react-day-picker'
+import {
+  CalendarIcon,
+  Clock,
+  Download,
+  Loader2,
+  MoreHorizontal,
+  Pencil,
+  Plus,
+  Square,
+  Timer,
+  Trash2,
+  Users,
+  Activity,
+  Gauge,
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { AppCard } from '@/components/app-card'
+import { useConfirm } from '@/components/confirm-dialog'
+import { useDateSettings } from '@/components/date-settings-context'
+import { useFormatDate } from '@/lib/use-format-date'
+import { addZonedDays, startOfZonedDay, zonedDayKey } from '@/lib/timezone'
+import { cn } from '@/lib/utils'
+import {
+  deleteTimeEntry,
+  getTimesheet,
+  stopTimeEntry,
+  type TimesheetData,
+} from '@/features/time-tracking/Actions/timesheetActions'
+import {
+  buildTimesheet,
+  entryMinutes,
+  formatElapsed,
+  formatMinutes,
+  type SheetEntry,
+  type TechnicianSheet,
+  type Timesheet,
+} from '@/features/time-tracking/Lib/timesheet'
+import { downloadText, timesheetCsv } from '@/features/time-tracking/Lib/timesheet-csv'
+import { useClockEvents } from '@/features/time-tracking/Components/TimeClockProvider'
+import { useTick } from '@/features/time-tracking/hooks/useTick'
+import { SourceBadge } from '@/features/time-tracking/Components/SourceBadge'
+import { TimeEntryDialog } from '@/features/time-tracking/Components/TimeEntryDialog'
+
+/** A day key as a Date that formats to that day in any zone within twelve hours of UTC. */
+function noonOf(dayKey: string): Date {
+  return new Date(`${dayKey}T12:00:00Z`)
+}
+
+/** A day key as the browser-local midnight the calendar picker works in. */
+function localDate(dayKey: string): Date {
+  const [y, m, d] = dayKey.split('-').map(Number)
+  return new Date(y, m - 1, d)
+}
+
+function localKey(date: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+}
+
+type Preset = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
+const PRESETS: Preset[] = ['today', 'yesterday', 'thisWeek', 'lastWeek', 'thisMonth', 'lastMonth']
+
+/**
+ * Preset windows on the workshop's calendar, honouring its week start.
+ *
+ * A week or a month is the whole of it, days still to come included, not
+ * "so far": on a Monday "this week" would otherwise be one day and read the
+ * same as "today", and the grid would grow a column each morning.
+ */
+function presetRange(
+  preset: Preset,
+  timeZone: string,
+  weekStartDay: number,
+  now: Date
+): { from: string; to: string } {
+  const today = startOfZonedDay(now, timeZone)
+  const key = (d: Date) => zonedDayKey(d, timeZone)
+  const shift = (d: Date, n: number) => addZonedDays(d, n, timeZone)
+  // Day of week in the workshop, 0 = Sunday, read off a noon instant so a
+  // DST edge cannot tip it into the wrong day.
+  const dow = new Date(today.getTime() + 12 * 3_600_000).getUTCDay()
+  const sinceWeekStart = (dow - weekStartDay + 7) % 7
+  const weekStart = shift(today, -sinceWeekStart)
+  const monthKey = key(today).slice(0, 7)
+  switch (preset) {
+    case 'today':
+      return { from: key(today), to: key(today) }
+    case 'yesterday': {
+      const y = shift(today, -1)
+      return { from: key(y), to: key(y) }
+    }
+    case 'thisWeek':
+      return { from: key(weekStart), to: key(shift(weekStart, 6)) }
+    case 'lastWeek':
+      return { from: key(shift(weekStart, -7)), to: key(shift(weekStart, -1)) }
+    case 'thisMonth': {
+      const [y, m] = monthKey.split('-').map(Number)
+      const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate()
+      return { from: `${monthKey}-01`, to: `${monthKey}-${String(lastDay).padStart(2, '0')}` }
+    }
+    case 'lastMonth': {
+      const [y, m] = monthKey.split('-').map(Number)
+      const prev = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
+      const lastDay = new Date(Date.UTC(y, m - 1, 0)).getUTCDate()
+      return { from: `${prev}-01`, to: `${prev}-${String(lastDay).padStart(2, '0')}` }
+    }
+  }
+}
+
+export default function TimesheetsClient({
+  initial,
+  initialError,
+}: {
+  initial: TimesheetData | null
+  initialError: string | null
+}) {
+  const t = useTranslations('timeTracking.page')
+  const router = useRouter()
+  const pathname = usePathname()
+  const format = useFormatter()
+  const { formatTime, formatDate } = useFormatDate()
+  const { weekStartDay } = useDateSettings()
+  const confirm = useConfirm()
+
+  const [data, setData] = useState<TimesheetData | null>(initial)
+  const [error, setError] = useState<string | null>(initialError)
+  const [loading, setLoading] = useState(false)
+  const [technicianId, setTechnicianId] = useState<string>('all')
+  const [dialog, setDialog] = useState<{
+    open: boolean
+    entry?: SheetEntry | null
+    defaults?: { technicianId?: string; dayKey?: string }
+  }>({ open: false })
+  const [stoppingId, setStoppingId] = useState<string | null>(null)
+
+  const timeZone = data?.timeZone ?? 'UTC'
+
+  const load = useCallback(
+    async (from: string, to: string, tech: string) => {
+      setLoading(true)
+      const result = await getTimesheet({ from, to, technicianId: tech === 'all' ? null : tech })
+      setLoading(false)
+      if (!result.success || !result.data) {
+        setError(result.error ?? t('loadFailed'))
+        return
+      }
+      setError(null)
+      setData(result.data)
+      const params = new URLSearchParams({ from, to })
+      if (tech !== 'all') params.set('tech', tech)
+      router.replace(`${pathname}?${params.toString()}`, { scroll: false })
+    },
+    [pathname, router, t]
+  )
+
+  // Any clock change anywhere in the workshop refreshes the sheet, coalesced
+  // so a technician switching jobs (one stop, one start) costs one fetch.
+  const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  useClockEvents(() => {
+    if (!data) return
+    if (refreshTimer.current) clearTimeout(refreshTimer.current)
+    refreshTimer.current = setTimeout(() => {
+      void load(data.fromKey, data.toKey, technicianId)
+    }, 300)
+  })
+  useEffect(() => {
+    return () => {
+      if (refreshTimer.current) clearTimeout(refreshTimer.current)
+    }
+  }, [])
+
+  const hasRunning = useMemo(() => data?.entries.some((e) => !e.endedAt) ?? false, [data])
+  const now = useTick(hasRunning)
+
+  const sheet: Timesheet | null = useMemo(() => {
+    if (!data) return null
+    const technicians =
+      technicianId === 'all'
+        ? data.technicians
+        : data.technicians.filter((tech) => tech.id === technicianId)
+    return buildTimesheet({
+      entries: data.entries,
+      technicians,
+      from: new Date(data.from),
+      to: new Date(data.to),
+      timeZone: data.timeZone,
+      now,
+    })
+  }, [data, technicianId, now])
+
+  const activePreset = useMemo<Preset | null>(() => {
+    if (!data) return null
+    for (const preset of PRESETS) {
+      const r = presetRange(preset, data.timeZone, weekStartDay, now)
+      if (r.from === data.fromKey && r.to === data.toKey) return preset
+    }
+    return null
+  }, [data, weekStartDay, now])
+
+  const dayLabel = useCallback(
+    (dayKey: string, style: 'short' | 'long') =>
+      format.dateTime(noonOf(dayKey), {
+        timeZone,
+        weekday: style === 'long' ? 'long' : 'short',
+        day: 'numeric',
+        month: style === 'long' ? 'long' : 'short',
+      }),
+    [format, timeZone]
+  )
+
+  const todayKey = zonedDayKey(now, timeZone)
+
+  const onSaved = () => {
+    if (data) void load(data.fromKey, data.toKey, technicianId)
+  }
+
+  const handleStop = async (entry: SheetEntry) => {
+    setStoppingId(entry.id)
+    const result = await stopTimeEntry(entry.id)
+    setStoppingId(null)
+    if (!result.success || !result.data) {
+      toast.error(result.error ?? t('loadFailed'))
+      return
+    }
+    toast.success(
+      t('stoppedByManager', { minutes: formatMinutes(result.data.durationMinutes ?? 0) })
+    )
+    onSaved()
+  }
+
+  const handleDelete = async (entry: SheetEntry) => {
+    const ok = await confirm({
+      title: t('deleteTitle'),
+      description: t('deleteBody', {
+        duration: formatMinutes(entryMinutes(entry, now)),
+        job: entry.job.title,
+        technician: entry.technicianName,
+      }),
+      confirmLabel: t('deleteConfirm'),
+      destructive: true,
+    })
+    if (!ok) return
+    const result = await deleteTimeEntry(entry.id)
+    if (!result.success) {
+      toast.error(result.error ?? t('loadFailed'))
+      return
+    }
+    toast.success(t('deleted'))
+    onSaved()
+  }
+
+  const exportCsv = () => {
+    if (!data || !sheet) return
+    const entries = sheet.technicians.flatMap((s) => s.entries)
+    const csv = timesheetCsv(entries, data.timeZone, now, [
+      t('csv.technician'),
+      t('csv.date'),
+      t('csv.start'),
+      t('csv.end'),
+      t('csv.duration'),
+      t('csv.hours'),
+      t('csv.job'),
+      t('csv.vehicle'),
+      t('csv.plate'),
+      t('csv.source'),
+      t('csv.editedBy'),
+      t('csv.note'),
+    ])
+    downloadText(`timesheet-${data.fromKey}-${data.toKey}.csv`, `${csv}`)
+  }
+
+  if (!data || !sheet) {
+    return (
+      <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
+        {error ?? t('loadFailed')}
+      </div>
+    )
+  }
+
+  const activeDays = [...sheet.dayTotals.values()].filter((m) => m > 0).length
+  const techniciansWithTime = sheet.technicians.filter((s) => s.totalMinutes > 0).length
+  const runningEntries = sheet.technicians.flatMap((s) => s.entries.filter((e) => !e.endedAt))
+
+  return (
+    <div className="space-y-4">
+      {/* ── Toolbar ─────────────────────────────────────────────────────── */}
+      <div className="flex flex-wrap items-center gap-2">
+        <div className="flex flex-wrap items-center gap-1 rounded-md border bg-muted/40 p-1">
+          {PRESETS.map((preset) => (
+            <Button
+              key={preset}
+              type="button"
+              size="sm"
+              variant={activePreset === preset ? 'default' : 'ghost'}
+              className="h-7 px-2.5 text-xs"
+              disabled={loading}
+              onClick={() => {
+                const r = presetRange(preset, timeZone, weekStartDay, new Date())
+                void load(r.from, r.to, technicianId)
+              }}
+            >
+              {t(`presets.${preset}`)}
+            </Button>
+          ))}
+          <RangePicker
+            fromKey={data.fromKey}
+            toKey={data.toKey}
+            active={activePreset === null}
+            disabled={loading}
+            label={
+              data.fromKey === data.toKey
+                ? formatDate(noonOf(data.fromKey))
+                : `${formatDate(noonOf(data.fromKey))} – ${formatDate(noonOf(data.toKey))}`
+            }
+            onApply={(from, to) => void load(from, to, technicianId)}
+          />
+        </div>
+
+        <div className="ml-auto flex flex-wrap items-center gap-2">
+          {loading && <Loader2 className="size-4 animate-spin text-muted-foreground" />}
+          <Select
+            value={technicianId}
+            onValueChange={(v) => {
+              setTechnicianId(v)
+              void load(data.fromKey, data.toKey, v)
+            }}
+          >
+            <SelectTrigger className="h-8 w-44" size="sm">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="all">{t('allTechnicians')}</SelectItem>
+              {data.technicians.map((tech) => (
+                <SelectItem key={tech.id} value={tech.id}>
+                  <span className="flex items-center gap-2">
+                    <span className="size-2 rounded-full" style={{ backgroundColor: tech.color }} />
+                    {tech.name}
+                  </span>
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          <Button
+            type="button"
+            size="sm"
+            variant="outline"
+            className="h-8"
+            onClick={exportCsv}
+            disabled={sheet.totalMinutes === 0}
+          >
+            <Download className="size-3.5" />
+            <span className="hidden sm:inline">{t('export')}</span>
+          </Button>
+          {data.canEdit && (
+            <Button
+              type="button"
+              size="sm"
+              className="h-8"
+              onClick={() => setDialog({ open: true, entry: null })}
+            >
+              <Plus className="size-3.5" />
+              <span className="hidden sm:inline">{t('addEntry')}</span>
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* ── Stats ───────────────────────────────────────────────────────── */}
+      <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
+        <StatTile
+          icon={Clock}
+          label={t('stats.total')}
+          value={formatMinutes(sheet.totalMinutes)}
+          hint={t('stats.totalHint', { days: sheet.dayKeys.length })}
+        />
+        <StatTile
+          icon={Users}
+          label={t('stats.technicians')}
+          value={String(techniciansWithTime)}
+          hint={t('stats.techniciansHint', { count: data.technicians.length })}
+        />
+        <StatTile
+          icon={Activity}
+          label={t('stats.running')}
+          value={String(sheet.runningCount)}
+          hint={t('stats.runningHint')}
+          live={sheet.runningCount > 0}
+        />
+        <StatTile
+          icon={Gauge}
+          label={t('stats.average')}
+          value={formatMinutes(activeDays ? sheet.totalMinutes / activeDays : 0)}
+          hint={t('stats.averageHint', { days: activeDays })}
+        />
+      </div>
+
+      {/* ── On the clock now ────────────────────────────────────────────── */}
+      {runningEntries.length > 0 && (
+        <div className="rounded-xl border border-primary/30 bg-primary/5 p-3">
+          <div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-primary">
+            <span className="relative flex size-2">
+              <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60" />
+              <span className="relative inline-flex size-2 rounded-full bg-primary" />
+            </span>
+            {t('nowStrip.title')}
+          </div>
+          <div className="flex flex-wrap gap-2">
+            {runningEntries.map((e) => (
+              <div
+                key={e.id}
+                className="flex items-center gap-2 rounded-lg border bg-background px-2.5 py-1.5 text-sm shadow-xs"
+              >
+                <span
+                  className="size-2.5 rounded-full"
+                  style={{ backgroundColor: e.technicianColor }}
+                />
+                <span className="font-medium">{e.technicianName}</span>
+                <Link
+                  href={jobHref(e)}
+                  className="max-w-48 truncate text-muted-foreground hover:text-foreground hover:underline"
+                >
+                  {e.job.title}
+                </Link>
+                <span className="font-mono text-xs tabular-nums text-primary">
+                  {formatElapsed(now.getTime() - new Date(e.startedAt).getTime())}
+                </span>
+                <span className="hidden text-xs text-muted-foreground sm:inline">
+                  {t('nowStrip.since', { time: formatTime(e.startedAt) })}
+                </span>
+                {data.canEdit && (
+                  <Button
+                    type="button"
+                    size="sm"
+                    variant="ghost"
+                    className="h-6 px-1.5 text-xs"
+                    disabled={stoppingId === e.id}
+                    onClick={() => void handleStop(e)}
+                  >
+                    {stoppingId === e.id ? (
+                      <Loader2 className="size-3 animate-spin" />
+                    ) : (
+                      <Square className="size-3 fill-current" />
+                    )}
+                    {t('nowStrip.stop')}
+                  </Button>
+                )}
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* ── Overview grid ───────────────────────────────────────────────── */}
+      {sheet.technicians.length > 0 && sheet.totalMinutes > 0 && (
+        <AppCard
+          icon={Timer}
+          title={t('grid.title')}
+          description={t('grid.description')}
+          contentClassName="p-0"
+        >
+          {sheet.dayKeys.length > 31 ? (
+            <p className="p-4 text-sm text-muted-foreground">{t('grid.tooLong')}</p>
+          ) : (
+            <DayGrid
+              sheet={sheet}
+              todayKey={todayKey}
+              dayLabel={dayLabel}
+              technicianHeader={t('grid.technician')}
+              totalHeader={t('grid.total')}
+            />
+          )}
+        </AppCard>
+      )}
+
+      {/* ── Entries ─────────────────────────────────────────────────────── */}
+      {sheet.totalMinutes === 0 && sheet.runningCount === 0 ? (
+        <div className="rounded-xl border border-dashed p-10 text-center">
+          <Timer className="mx-auto mb-3 size-8 text-muted-foreground/60" />
+          <p className="font-medium">{t('empty.title')}</p>
+          <p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
+            {data.technicians.length === 0 ? t('empty.noTechnicians') : t('empty.body')}
+          </p>
+          {data.technicians.length === 0 && (
+            <Button asChild variant="outline" size="sm" className="mt-4">
+              <Link href="/settings/team">{t('empty.teamLink')}</Link>
+            </Button>
+          )}
+        </div>
+      ) : (
+        sheet.technicians
+          .filter((s) => s.entries.length > 0)
+          .map((s) => (
+            <TechnicianCard
+              key={s.technician.id}
+              sheet={s}
+              now={now}
+              timeZone={timeZone}
+              canEdit={data.canEdit}
+              dayLabel={dayLabel}
+              formatTime={formatTime}
+              stoppingId={stoppingId}
+              onEdit={(entry) => setDialog({ open: true, entry })}
+              onStop={(entry) => void handleStop(entry)}
+              onDelete={(entry) => void handleDelete(entry)}
+              onAdd={(dayKey) =>
+                setDialog({
+                  open: true,
+                  entry: null,
+                  defaults: { technicianId: s.technician.id, dayKey },
+                })
+              }
+            />
+          ))
+      )}
+
+      <TimeEntryDialog
+        open={dialog.open}
+        onOpenChange={(open) => setDialog((d) => ({ ...d, open }))}
+        timeZone={timeZone}
+        technicians={data.technicians}
+        entry={dialog.entry}
+        defaults={dialog.defaults}
+        onSaved={onSaved}
+      />
+    </div>
+  )
+}
+
+function jobHref(e: SheetEntry): string {
+  return e.job.vehicleId ? `/vehicles/${e.job.vehicleId}/service/${e.job.id}` : `/sales/${e.job.id}`
+}
+
+function StatTile({
+  icon: Icon,
+  label,
+  value,
+  hint,
+  live,
+}: {
+  icon: typeof Clock
+  label: string
+  value: string
+  hint: string
+  live?: boolean
+}) {
+  return (
+    <div className="relative overflow-hidden rounded-xl border border-card-edge bg-card p-3 shadow-[0_1px_2px_rgb(0_0_0/0.05)]">
+      <div className="flex items-center gap-2 text-xs text-muted-foreground">
+        <Icon className="size-3.5" />
+        <span className="truncate">{label}</span>
+        {live && (
+          <span className="relative ml-auto flex size-2">
+            <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60" />
+            <span className="relative inline-flex size-2 rounded-full bg-primary" />
+          </span>
+        )}
+      </div>
+      <div className="mt-1 font-mono text-2xl font-semibold tabular-nums leading-tight">
+        {value}
+      </div>
+      <div className="mt-0.5 truncate text-xs text-muted-foreground">{hint}</div>
+    </div>
+  )
+}
+
+function RangePicker({
+  fromKey,
+  toKey,
+  active,
+  disabled,
+  label,
+  onApply,
+}: {
+  fromKey: string
+  toKey: string
+  active: boolean
+  disabled: boolean
+  label: string
+  onApply: (from: string, to: string) => void
+}) {
+  const t = useTranslations('timeTracking.page')
+  const [open, setOpen] = useState(false)
+  const [pending, setPending] = useState<DateRange | undefined>()
+
+  return (
+    <Popover
+      open={open}
+      onOpenChange={(o) => {
+        setOpen(o)
+        if (o) setPending({ from: localDate(fromKey), to: localDate(toKey) })
+      }}
+    >
+      <PopoverTrigger asChild>
+        <Button
+          type="button"
+          size="sm"
+          variant={active ? 'default' : 'ghost'}
+          className="h-7 px-2.5 text-xs"
+          disabled={disabled}
+        >
+          <CalendarIcon className="size-3.5" />
+          {active ? label : t('customRange')}
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-auto p-0" align="start">
+        <Calendar
+          mode="range"
+          numberOfMonths={2}
+          defaultMonth={localDate(fromKey)}
+          selected={pending}
+          onSelect={setPending}
+        />
+        <div className="flex justify-end border-t p-2">
+          <Button
+            type="button"
+            size="sm"
+            disabled={!pending?.from}
+            onClick={() => {
+              if (!pending?.from) return
+              const from = localKey(pending.from)
+              const to = localKey(pending.to ?? pending.from)
+              setOpen(false)
+              onApply(from, to)
+            }}
+          >
+            {t('apply')}
+          </Button>
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}
+
+/**
+ * Technicians down, days across, minutes in the cells shaded by how full a
+ * day was against an eight hour reference. The eye finds the empty Tuesday
+ * and the fourteen hour Friday before any total does.
+ */
+function DayGrid({
+  sheet,
+  todayKey,
+  dayLabel,
+  technicianHeader,
+  totalHeader,
+}: {
+  sheet: Timesheet
+  todayKey: string
+  dayLabel: (dayKey: string, style: 'short' | 'long') => string
+  technicianHeader: string
+  totalHeader: string
+}) {
+  const shade = (minutes: number) => {
+    if (minutes <= 0) return undefined
+    const alpha = 0.12 + Math.min(1, minutes / 480) * 0.5
+    return {
+      backgroundColor: `color-mix(in oklab, var(--primary) ${Math.round(alpha * 100)}%, transparent)`,
+    }
+  }
+  // A day key is already the workshop's day, so its weekday needs no zone.
+  const isWeekend = (dayKey: string) => {
+    const dow = new Date(`${dayKey}T12:00:00Z`).getUTCDay()
+    return dow === 0 || dow === 6
+  }
+  const short = (minutes: number) => {
+    if (minutes <= 0) return ''
+    const h = Math.floor(minutes / 60)
+    const m = minutes % 60
+    return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
+  }
+
+  return (
+    <div className="overflow-x-auto">
+      <table className="w-full border-collapse text-xs">
+        <thead>
+          <tr className="border-b">
+            <th className="sticky left-0 z-10 bg-card px-3 py-2 text-left font-medium text-muted-foreground">
+              {technicianHeader}
+            </th>
+            {sheet.dayKeys.map((day) => (
+              <th
+                key={day}
+                className={cn(
+                  'min-w-14 px-1 py-2 text-center font-medium',
+                  isWeekend(day) ? 'text-muted-foreground/60' : 'text-muted-foreground',
+                  day === todayKey && 'text-primary'
+                )}
+                title={dayLabel(day, 'long')}
+              >
+                <div className="leading-tight">
+                  {dayLabel(day, 'short')
+                    .split(' ')[0]
+                    .replace(/[,.]+$/, '')}
+                </div>
+                <div className="font-normal opacity-70">{day.slice(8)}</div>
+              </th>
+            ))}
+            <th className="px-3 py-2 text-right font-medium text-muted-foreground">
+              {totalHeader}
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          {sheet.technicians.map((s) => (
+            <tr key={s.technician.id} className="border-b last:border-b-0">
+              <td className="sticky left-0 z-10 bg-card px-3 py-1.5">
+                <span className="flex items-center gap-2 whitespace-nowrap font-medium">
+                  <span
+                    className="size-2 shrink-0 rounded-full"
+                    style={{ backgroundColor: s.technician.color }}
+                  />
+                  {s.technician.name}
+                </span>
+              </td>
+              {sheet.dayKeys.map((day) => {
+                const minutes = s.byDay.get(day) ?? 0
+                return (
+                  <td key={day} className="p-0.5">
+                    <a
+                      href={`#day-${s.technician.id}-${day}`}
+                      className={cn(
+                        'flex h-8 items-center justify-center rounded font-mono tabular-nums transition-colors',
+                        minutes > 0 ? 'hover:ring-1 hover:ring-primary/50' : 'text-transparent',
+                        isWeekend(day) && minutes === 0 && 'bg-muted/40'
+                      )}
+                      style={shade(minutes)}
+                      aria-label={`${s.technician.name}, ${dayLabel(day, 'long')}: ${formatMinutes(minutes)}`}
+                    >
+                      {short(minutes)}
+                    </a>
+                  </td>
+                )
+              })}
+              <td className="px-3 py-1.5 text-right font-mono font-semibold tabular-nums">
+                {formatMinutes(s.totalMinutes)}
+              </td>
+            </tr>
+          ))}
+        </tbody>
+        <tfoot>
+          <tr className="border-t bg-muted/30">
+            <td className="sticky left-0 z-10 bg-muted/30 px-3 py-1.5 font-medium text-muted-foreground backdrop-blur">
+              {totalHeader}
+            </td>
+            {sheet.dayKeys.map((day) => (
+              <td key={day} className="px-1 py-1.5 text-center font-mono tabular-nums">
+                {short(sheet.dayTotals.get(day) ?? 0)}
+              </td>
+            ))}
+            <td className="px-3 py-1.5 text-right font-mono font-semibold tabular-nums">
+              {formatMinutes(sheet.totalMinutes)}
+            </td>
+          </tr>
+        </tfoot>
+      </table>
+    </div>
+  )
+}
+
+function TechnicianCard({
+  sheet,
+  now,
+  timeZone,
+  canEdit,
+  dayLabel,
+  formatTime,
+  stoppingId,
+  onEdit,
+  onStop,
+  onDelete,
+  onAdd,
+}: {
+  sheet: TechnicianSheet
+  now: Date
+  timeZone: string
+  canEdit: boolean
+  dayLabel: (dayKey: string, style: 'short' | 'long') => string
+  formatTime: (date: string) => string
+  stoppingId: string | null
+  onEdit: (entry: SheetEntry) => void
+  onStop: (entry: SheetEntry) => void
+  onDelete: (entry: SheetEntry) => void
+  onAdd: (dayKey: string) => void
+}) {
+  const t = useTranslations('timeTracking.page.table')
+  const tPage = useTranslations('timeTracking.page')
+
+  // Entries grouped under the day they started, newest day first, newest
+  // stretch first within it. A stretch that crossed midnight is listed once,
+  // under its start; the grid above already splits its minutes.
+  const days = useMemo(() => {
+    const map = new Map<string, SheetEntry[]>()
+    for (const e of sheet.entries) {
+      const key = zonedDayKey(new Date(e.startedAt), timeZone)
+      const list = map.get(key) ?? []
+      list.push(e)
+      map.set(key, list)
+    }
+    return [...map.entries()]
+      .sort(([a], [b]) => (a < b ? 1 : -1))
+      .map(([key, list]) => ({
+        key,
+        entries: list.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1)),
+        minutes: list.reduce((sum, e) => sum + entryMinutes(e, now), 0),
+      }))
+  }, [sheet.entries, timeZone, now])
+
+  return (
+    <AppCard
+      title={
+        <span className="flex items-center gap-2">
+          <span
+            className="size-2.5 rounded-full"
+            style={{ backgroundColor: sheet.technician.color }}
+          />
+          {sheet.technician.name}
+          {sheet.technician.linked === false && (
+            // Two rows can share a name; this is what tells them apart.
+            <span
+              className="rounded border px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
+              title={tPage('unlinkedHint')}
+            >
+              {tPage('unlinked')}
+            </span>
+          )}
+        </span>
+      }
+      badge={formatMinutes(sheet.totalMinutes)}
+      contentClassName="p-0"
+    >
+      {days.map((day) => (
+        <section
+          key={day.key}
+          id={`day-${sheet.technician.id}-${day.key}`}
+          className="scroll-mt-20"
+        >
+          <div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-1.5 text-xs">
+            <span className="font-medium">{dayLabel(day.key, 'long')}</span>
+            <span className="ml-auto font-mono tabular-nums text-muted-foreground">
+              {t('dayTotal')}: {formatMinutes(day.minutes)}
+            </span>
+            {canEdit && (
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                className="size-6"
+                onClick={() => onAdd(day.key)}
+                aria-label={t('actions')}
+                title={t('actions')}
+              >
+                <Plus className="size-3.5" />
+              </Button>
+            )}
+          </div>
+
+          {/* Table above md */}
+          <table className="hidden w-full text-sm md:table">
+            <tbody>
+              {day.entries.map((e) => (
+                <EntryRow
+                  key={e.id}
+                  entry={e}
+                  now={now}
+                  canEdit={canEdit}
+                  formatTime={formatTime}
+                  stoppingId={stoppingId}
+                  onEdit={onEdit}
+                  onStop={onStop}
+                  onDelete={onDelete}
+                />
+              ))}
+            </tbody>
+          </table>
+
+          {/* Cards below md */}
+          <ul className="divide-y md:hidden">
+            {day.entries.map((e) => (
+              <EntryCard
+                key={e.id}
+                entry={e}
+                now={now}
+                canEdit={canEdit}
+                formatTime={formatTime}
+                stoppingId={stoppingId}
+                onEdit={onEdit}
+                onStop={onStop}
+                onDelete={onDelete}
+              />
+            ))}
+          </ul>
+        </section>
+      ))}
+    </AppCard>
+  )
+}
+
+type RowProps = {
+  entry: SheetEntry
+  now: Date
+  canEdit: boolean
+  formatTime: (date: string) => string
+  stoppingId: string | null
+  onEdit: (entry: SheetEntry) => void
+  onStop: (entry: SheetEntry) => void
+  onDelete: (entry: SheetEntry) => void
+}
+
+function EntryActions({ entry, canEdit, stoppingId, onEdit, onStop, onDelete }: RowProps) {
+  const t = useTranslations('timeTracking.page.table')
+  if (!canEdit) return null
+  const running = !entry.endedAt
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="size-7"
+          aria-label={t('actions')}
+          disabled={stoppingId === entry.id}
+        >
+          {stoppingId === entry.id ? (
+            <Loader2 className="size-3.5 animate-spin" />
+          ) : (
+            <MoreHorizontal className="size-4" />
+          )}
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end">
+        {running && (
+          <DropdownMenuItem onClick={() => onStop(entry)}>
+            <Square className="size-3.5 fill-current" />
+            {t('stop')}
+          </DropdownMenuItem>
+        )}
+        <DropdownMenuItem onClick={() => onEdit(entry)}>
+          <Pencil className="size-3.5" />
+          {t('edit')}
+        </DropdownMenuItem>
+        <DropdownMenuSeparator />
+        <DropdownMenuItem variant="destructive" onClick={() => onDelete(entry)}>
+          <Trash2 className="size-3.5" />
+          {t('delete')}
+        </DropdownMenuItem>
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}
+
+function EntryRow(props: RowProps) {
+  const { entry, now, formatTime } = props
+  const t = useTranslations('timeTracking.page.table')
+  const running = !entry.endedAt
+  return (
+    <tr className={cn('border-b last:border-b-0', running && 'bg-primary/5')}>
+      <td className="whitespace-nowrap px-3 py-2 font-mono text-xs tabular-nums text-muted-foreground">
+        {formatTime(entry.startedAt)}
+        <span className="mx-1 opacity-60">–</span>
+        {running ? (
+          <span className="text-primary">{t('running')}</span>
+        ) : (
+          formatTime(entry.endedAt as string)
+        )}
+      </td>
+      <td className="w-24 whitespace-nowrap px-3 py-2 font-mono text-sm font-medium tabular-nums">
+        {running
+          ? formatElapsed(now.getTime() - new Date(entry.startedAt).getTime())
+          : formatMinutes(entryMinutes(entry, now))}
+      </td>
+      <td className="max-w-64 px-3 py-2">
+        <Link href={jobHref(entry)} className="block truncate hover:underline">
+          {entry.job.title}
+        </Link>
+      </td>
+      <td className="whitespace-nowrap px-3 py-2 text-xs text-muted-foreground">
+        {entry.job.licensePlate ? (
+          <span className="inline-flex items-center gap-1.5">
+            <span className="rounded border px-1 font-mono text-[11px] font-semibold uppercase text-foreground">
+              {entry.job.licensePlate}
+            </span>
+            <span className="hidden xl:inline">{entry.job.vehicleLabel}</span>
+          </span>
+        ) : (
+          (entry.job.vehicleLabel ?? t('counterSale'))
+        )}
+      </td>
+      <td className="px-3 py-2">
+        <SourceBadge source={entry.source} editedByName={entry.editedByName} />
+      </td>
+      <td
+        className="max-w-56 truncate px-3 py-2 text-xs text-muted-foreground"
+        title={entry.note ?? ''}
+      >
+        {entry.note}
+      </td>
+      <td className="w-10 px-2 py-1 text-right">
+        <EntryActions {...props} />
+      </td>
+    </tr>
+  )
+}
+
+function EntryCard(props: RowProps) {
+  const { entry, now, formatTime } = props
+  const t = useTranslations('timeTracking.page.table')
+  const running = !entry.endedAt
+  return (
+    <li className={cn('flex items-start gap-3 px-3 py-2.5', running && 'bg-primary/5')}>
+      <div className="min-w-0 flex-1 space-y-1">
+        <div className="flex items-center gap-2">
+          <span className="font-mono text-xs tabular-nums text-muted-foreground">
+            {formatTime(entry.startedAt)}
+            {' – '}
+            {running ? (
+              <span className="text-primary">{t('running')}</span>
+            ) : (
+              formatTime(entry.endedAt as string)
+            )}
+          </span>
+          <span className="ml-auto font-mono text-sm font-medium tabular-nums">
+            {running
+              ? formatElapsed(now.getTime() - new Date(entry.startedAt).getTime())
+              : formatMinutes(entryMinutes(entry, now))}
+          </span>
+        </div>
+        <Link href={jobHref(entry)} className="block truncate text-sm hover:underline">
+          {entry.job.title}
+        </Link>
+        <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+          {entry.job.licensePlate && (
+            <span className="rounded border px-1 font-mono text-[11px] font-semibold uppercase text-foreground">
+              {entry.job.licensePlate}
+            </span>
+          )}
+          <SourceBadge source={entry.source} editedByName={entry.editedByName} />
+          {entry.note && <span className="truncate">{entry.note}</span>}
+        </div>
+      </div>
+      <EntryActions {...props} />
+    </li>
+  )
+}
+
+/** Exposed for the CSV button's filename and tests; keeps the day math in one place. */
+export { presetRange }

+ 11 - 1
src/components/app-sidebar.tsx

@@ -66,6 +66,7 @@ import {
   Settings,
   Ship,
   ShieldCheck,
+  Timer,
   Users,
 } from 'lucide-react'
 import { useLocale, useTranslations } from 'next-intl'
@@ -130,6 +131,7 @@ export function AppSidebar({
   visibleSubjects,
   announcement = null,
   counts,
+  isTechnician = false,
   ...props
 }: React.ComponentProps<typeof Sidebar> & {
   companyLogo?: string
@@ -143,6 +145,8 @@ export function AppSidebar({
   /** The one product announcement to show, worked out on the server. */
   announcement?: string | null
   counts?: SidebarCounts
+  /** Linked to a technician row: the dashboard is their job list even without the figures. */
+  isTechnician?: boolean
 }) {
   const pathname = usePathname()
   const router = useRouter()
@@ -263,6 +267,12 @@ export function AppSidebar({
           },
         ]
       : []),
+    {
+      titleKey: 'sidebar.timesheets' as const,
+      url: '/timesheets',
+      icon: Timer,
+      subject: 'time_tracking',
+    },
     {
       titleKey: 'sidebar.auditLog' as const,
       url: '/audit-log',
@@ -512,7 +522,7 @@ export function AppSidebar({
       </SidebarHeader>
       <SidebarContent className="gap-0">
         {/* Dashboard */}
-        {canAccess('dashboard') && (
+        {(canAccess('dashboard') || isTechnician) && (
           <SidebarGroup className="pb-0">
             <SidebarMenu className="gap-1">
               <SidebarMenuItem>

+ 2 - 0
src/components/mobile-bottom-nav.tsx

@@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import {
   BarChart3,
+  Timer,
   Bell,
   CalendarDays,
   Car,
@@ -69,6 +70,7 @@ const businessItems = [
   { href: '/billing', icon: Receipt, labelKey: 'billing' },
   { href: '/labor-presets', icon: Layers, labelKey: 'laborPresets' },
   { href: '/reports', icon: BarChart3, labelKey: 'reports' },
+  { href: '/timesheets', icon: Timer, labelKey: 'timesheets' },
   { href: '/audit-log', icon: History, labelKey: 'auditLog' },
 ] as const
 

+ 3 - 0
src/components/page-header.tsx

@@ -22,6 +22,7 @@ import { useLicenseExpiry } from '@/components/license-expiry-context'
 import { BANNER_PRIORITY, useBannerSlot } from '@/components/banner-slot'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { DocsLink } from '@/components/docs-link'
+import { RunningClockPill } from '@/features/time-tracking/Components/RunningClockPill'
 
 function SearchTrigger() {
   const t = useTranslations('navigation')
@@ -139,6 +140,7 @@ const breadcrumbMap: Record<string, BreadcrumbSegment[]> = {
   '/billing': [{ key: 'billing', href: '/billing' }, { key: 'billingHistory' }],
   '/inventory': [{ key: 'inventory', href: '/inventory' }, { key: 'allParts' }],
   '/reports': [{ key: 'reports', href: '/reports' }, { key: 'reports' }],
+  '/timesheets': [{ key: 'timesheets' }],
   '/reminders': [{ key: 'reminders', href: '/reminders' }, { key: 'allReminders' }],
   '/work-board': [{ key: 'workBoard' }],
   '/work-board/presenter': [{ key: 'workBoard', href: '/work-board' }, { key: 'presenter' }],
@@ -291,6 +293,7 @@ export function PageHeader() {
         </Breadcrumb>
         <div className="ml-auto flex items-center gap-2">
           {docsHref && <DocsLink href={docsHref} variant="header" className="hidden sm:flex" />}
+          <RunningClockPill />
           <PlateTrigger />
           <SearchTrigger />
           <QuickCreateMenu />

+ 94 - 0
src/features/reports/Actions/reportActions.ts

@@ -1,6 +1,7 @@
 'use server'
 
 import { db } from '@/lib/db'
+import { listOrgEntries } from '@/features/time-tracking/Lib/timeEntries'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { netLineTotal } from '@/lib/tax'
@@ -287,6 +288,99 @@ export async function getTechnicianReport(params: { startDate?: string; endDate?
   )
 }
 
+/**
+ * Clocked against billed, per technician.
+ *
+ * Clocked minutes come from the time entries, clipped to the window so a
+ * night shift that crossed into it counts only the part inside. Billed
+ * hours are the labor lines on the jobs assigned to the technician that
+ * started in the window, the same figure the overview tab shows. The ratio
+ * is the efficiency a workshop actually manages by: hours it could invoice
+ * for every hour it paid.
+ */
+export async function getTechnicianTimeReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { start, end } = await reportWindow(organizationId, params)
+      const now = new Date()
+
+      const [entries, records, technicians] = await Promise.all([
+        listOrgEntries({ organizationId, from: start, to: end }),
+        db.serviceRecord.findMany({
+          where: {
+            organizationId,
+            startDateTime: { gte: start, lt: end },
+            technicianId: { not: null },
+          },
+          select: { technicianId: true, laborItems: { select: { hours: true } } },
+        }),
+        db.technician.findMany({
+          where: { organizationId },
+          select: { id: true, name: true, color: true, user: { select: { name: true } } },
+        }),
+      ])
+
+      const byTech = new Map<
+        string,
+        { clockedMinutes: number; billedHours: number; jobs: Set<string> }
+      >()
+      const row = (id: string) => {
+        let r = byTech.get(id)
+        if (!r) {
+          r = { clockedMinutes: 0, billedHours: 0, jobs: new Set() }
+          byTech.set(id, r)
+        }
+        return r
+      }
+
+      for (const e of entries) {
+        const from = e.startedAt < start ? start : e.startedAt
+        const rawEnd = e.endedAt ?? now
+        const to = rawEnd > end ? end : rawEnd
+        const minutes = Math.max(0, Math.round((to.getTime() - from.getTime()) / 60_000))
+        if (minutes === 0) continue
+        const r = row(e.technicianId)
+        r.clockedMinutes += minutes
+        r.jobs.add(e.serviceRecord.id)
+      }
+      for (const rec of records) {
+        if (!rec.technicianId) continue
+        row(rec.technicianId).billedHours += rec.laborItems.reduce((s, l) => s + l.hours, 0)
+      }
+
+      const names = new Map(
+        technicians.map((t) => [t.id, { name: t.user?.name || t.name, color: t.color }])
+      )
+      const list = [...byTech.entries()]
+        .map(([technicianId, r]) => {
+          const clockedHours = r.clockedMinutes / 60
+          return {
+            technicianId,
+            techName: names.get(technicianId)?.name ?? 'Unknown',
+            color: names.get(technicianId)?.color ?? '#3b82f6',
+            clockedMinutes: r.clockedMinutes,
+            billedHours: r.billedHours,
+            efficiency: clockedHours > 0 ? (r.billedHours / clockedHours) * 100 : null,
+            jobsClocked: r.jobs.size,
+          }
+        })
+        .filter((t) => t.clockedMinutes > 0 || t.billedHours > 0)
+        .sort((a, b) => b.clockedMinutes - a.clockedMinutes)
+
+      const totalClockedMinutes = list.reduce((s, t) => s + t.clockedMinutes, 0)
+      const totalBilledHours = list.reduce((s, t) => s + t.billedHours, 0)
+      return {
+        technicians: list,
+        totalClockedMinutes,
+        totalBilledHours,
+        efficiency:
+          totalClockedMinutes > 0 ? (totalBilledHours / (totalClockedMinutes / 60)) * 100 : null,
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
+}
+
 export async function getPartsUsageReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {

+ 66 - 2
src/features/reports/Components/TechnicianCharts.tsx

@@ -1,7 +1,16 @@
 'use client'
 
-import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
-import type { TechnicianMetrics } from '../Schema/reportTypes'
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  Legend,
+  ResponsiveContainer,
+} from 'recharts'
+import type { TechnicianMetrics, TechnicianTimeMetrics } from '../Schema/reportTypes'
 
 interface TechnicianBarChartProps {
   data: TechnicianMetrics[]
@@ -58,3 +67,58 @@ export function TechnicianBarChart({ data, formatCurrency, labels }: TechnicianB
     </ResponsiveContainer>
   )
 }
+
+interface TechnicianTimeChartProps {
+  data: TechnicianTimeMetrics[]
+  labels: { clocked: string; billed: string }
+}
+
+/** Clocked beside billed, one pair of bars per technician; the gap is the story. */
+export function TechnicianTimeChart({ data, labels }: TechnicianTimeChartProps) {
+  if (data.length === 0) return null
+
+  const chartData = data.slice(0, 10).map((t) => ({
+    name: t.techName.length > 20 ? `${t.techName.slice(0, 18)}\u2026` : t.techName,
+    clocked: Math.round((t.clockedMinutes / 60) * 10) / 10,
+    billed: Math.round(t.billedHours * 10) / 10,
+  }))
+
+  return (
+    <ResponsiveContainer width="100%" height={Math.max(220, chartData.length * 48)}>
+      <BarChart
+        data={chartData}
+        layout="vertical"
+        margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
+        barGap={2}
+      >
+        <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
+        <XAxis
+          type="number"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          tickFormatter={(v) => `${v} h`}
+        />
+        <YAxis
+          type="category"
+          dataKey="name"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          width={140}
+        />
+        <Tooltip
+          formatter={(value) => `${Number(value).toFixed(1)} h`}
+          wrapperStyle={{ outline: 'none' }}
+          contentStyle={{
+            backgroundColor: 'var(--popover)',
+            border: '1px solid var(--border)',
+            borderRadius: '8px',
+            color: 'var(--popover-foreground)',
+          }}
+        />
+        <Legend wrapperStyle={{ fontSize: 12 }} />
+        <Bar dataKey="clocked" name={labels.clocked} fill="var(--primary)" radius={[0, 4, 4, 0]} />
+        <Bar dataKey="billed" name={labels.billed} fill="#8b5cf6" radius={[0, 4, 4, 0]} />
+      </BarChart>
+    </ResponsiveContainer>
+  )
+}

+ 27 - 0
src/features/reports/Components/csv-export.ts

@@ -4,6 +4,7 @@ import type {
   CustomerReport,
   InventoryReport,
   TechnicianReport,
+  TechnicianTimeReport,
   PartsUsageReport,
   JobAnalyticsReport,
   CustomerRetentionReport,
@@ -339,3 +340,29 @@ export function exportTaxCsv(
     'invoiceCount',
   ])
 }
+
+export function exportTechnicianTimeCsv(
+  data: TechnicianTimeReport,
+  headers: [string, string, string, string, string] = [
+    'Technician',
+    'Clocked Hours',
+    'Billed Hours',
+    'Efficiency',
+    'Jobs Clocked',
+  ]
+) {
+  const rows = data.technicians.map((t) => ({
+    technician: t.techName,
+    clocked: (t.clockedMinutes / 60).toFixed(2),
+    billed: t.billedHours.toFixed(2),
+    efficiency: t.efficiency === null ? '' : `${t.efficiency.toFixed(0)}%`,
+    jobs: t.jobsClocked,
+  }))
+  downloadCsv('technician-time-report.csv', headers, rows, [
+    'technician',
+    'clocked',
+    'billed',
+    'efficiency',
+    'jobs',
+  ])
+}

+ 22 - 0
src/features/reports/Schema/reportTypes.ts

@@ -100,6 +100,28 @@ export interface TechnicianReport {
   totalRevenue: number
 }
 
+/** Clocked against billed, per technician, for one window. */
+export interface TechnicianTimeMetrics {
+  technicianId: string
+  techName: string
+  color: string
+  /** Minutes actually clocked inside the window, running clocks counted to now. */
+  clockedMinutes: number
+  /** Hours on labor lines of the jobs assigned to this technician in the window. */
+  billedHours: number
+  /** billedHours / clockedHours as a percentage; null when nothing was clocked. */
+  efficiency: number | null
+  jobsClocked: number
+}
+
+export interface TechnicianTimeReport {
+  technicians: TechnicianTimeMetrics[]
+  totalClockedMinutes: number
+  totalBilledHours: number
+  /** Overall billed / clocked, null when nothing was clocked. */
+  efficiency: number | null
+}
+
 export interface PartUsage {
   name: string
   partNumber: string | null

+ 141 - 0
src/features/time-tracking/Actions/timeClockActions.ts

@@ -0,0 +1,141 @@
+'use server'
+
+import { z } from 'zod'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import {
+  getOpenEntry,
+  listJobEntries,
+  startEntry,
+  stopEntry,
+  technicianIdsForUser,
+  TimeEntryError,
+} from '../Lib/timeEntries'
+import { toSheetEntries } from '../Lib/serialize'
+import { canEditTimeEntries } from '../Lib/canEdit'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import type { SheetEntry } from '../Lib/timesheet'
+
+/**
+ * The signed-in person's own clock, from the browser.
+ *
+ * Same rules as the technician app, same functions underneath; only the way
+ * in differs. A workshop whose technicians carry iPhones gets a clock this
+ * way today rather than when an iOS app exists.
+ */
+
+export interface MyClock {
+  /** Empty when the account is not linked to a technician row. */
+  technicianIds: string[]
+  open: {
+    id: string
+    startedAt: string
+    serviceRecordId: string
+    jobTitle: string
+    vehicleId: string | null
+  } | null
+}
+
+export async function getMyClock() {
+  return withAuth(async (ctx): Promise<MyClock> => {
+    const technicianIds = await technicianIdsForUser(ctx.organizationId, ctx.userId)
+    if (technicianIds.length === 0) return { technicianIds, open: null }
+    const open = await getOpenEntry(ctx.organizationId, technicianIds)
+    return {
+      technicianIds,
+      open: open
+        ? {
+            id: open.id,
+            startedAt: open.startedAt.toISOString(),
+            serviceRecordId: open.serviceRecordId,
+            jobTitle: open.serviceRecord.title,
+            vehicleId: open.serviceRecord.vehicleId,
+          }
+        : null,
+    }
+  })
+}
+
+const startSchema = z.object({ serviceRecordId: z.string().min(1) })
+
+export async function startMyClock(input: { serviceRecordId: string }) {
+  return withAuth(
+    async (ctx) => {
+      const { serviceRecordId } = startSchema.parse(input)
+      const technicianIds = await technicianIdsForUser(ctx.organizationId, ctx.userId)
+      if (technicianIds.length === 0) {
+        throw new TimeEntryError('job_not_found', 'This account is not set up as a technician.')
+      }
+      const result = await startEntry({
+        organizationId: ctx.organizationId,
+        technicianId: technicianIds[0],
+        technicianIds,
+        serviceRecordId,
+        source: 'web',
+      })
+      return {
+        startedAt: result.entry.startedAt.toISOString(),
+        closed: result.closed
+          ? {
+              serviceRecordId: result.closed.serviceRecordId,
+              minutes: result.closed.durationMinutes,
+            }
+          : null,
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}
+
+const stopSchema = z.object({ note: z.string().max(500).optional() })
+
+export async function stopMyClock(input: { note?: string } = {}) {
+  return withAuth(
+    async (ctx) => {
+      const { note } = stopSchema.parse(input)
+      const technicianIds = await technicianIdsForUser(ctx.organizationId, ctx.userId)
+      if (technicianIds.length === 0) {
+        throw new TimeEntryError('not_running', 'No clock is running.')
+      }
+      const entry = await stopEntry({ organizationId: ctx.organizationId, technicianIds, note })
+      return { serviceRecordId: entry.serviceRecordId, minutes: entry.durationMinutes ?? 0 }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}
+
+/** What the work order page shows: every stretch on one job, by anyone. */
+export interface JobClock {
+  entries: SheetEntry[]
+  /** Whether the viewer can clock themselves onto this job. */
+  viewerTechnicianIds: string[]
+  /** Whether the viewer may correct, stop or delete entries here. */
+  canEdit: boolean
+  /** The workshop's zone, for the correction dialog's wall-clock fields. */
+  timeZone: string
+}
+
+export async function getJobClock(serviceRecordId: string) {
+  return withAuth(
+    async (ctx): Promise<JobClock> => {
+      const [rows, viewerTechnicianIds, canEdit, timeZone] = await Promise.all([
+        listJobEntries(ctx.organizationId, serviceRecordId),
+        technicianIdsForUser(ctx.organizationId, ctx.userId),
+        canEditTimeEntries(ctx),
+        workshopTimeZone(ctx.organizationId),
+      ])
+      return { entries: await toSheetEntries(rows), viewerTechnicianIds, canEdit, timeZone }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 317 - 0
src/features/time-tracking/Actions/timesheetActions.ts

@@ -0,0 +1,317 @@
+'use server'
+
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { auditDetails } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { addZonedDays, startOfZonedDay, zonedDayKey } from '@/lib/timezone'
+import { parseWorkshopDateTime, workshopDayRange } from '@/lib/workshop-datetime'
+import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { createManualEntry, deleteEntry, listOrgEntries, updateEntry } from '../Lib/timeEntries'
+import { toSheetEntries } from '../Lib/serialize'
+import { canEditTimeEntries } from '../Lib/canEdit'
+import type { SheetEntry, SheetTechnician } from '../Lib/timesheet'
+
+/**
+ * The manager's side of the clock: who worked when, and fixing what the
+ * clock got wrong.
+ *
+ * Reading needs READ on time tracking; changing anything needs UPDATE. Both
+ * are their own subject rather than riding on reports, because a foreman
+ * who may correct a forgotten clock-out is not necessarily someone who may
+ * read the revenue report.
+ */
+
+export interface TimesheetData {
+  timeZone: string
+  /** Whole workshop days, as ISO instants: `from` inclusive, `to` exclusive. */
+  from: string
+  to: string
+  fromKey: string
+  toKey: string
+  technicians: SheetTechnician[]
+  entries: SheetEntry[]
+  canEdit: boolean
+}
+
+const rangeSchema = z.object({
+  from: z
+    .string()
+    .regex(/^\d{4}-\d{2}-\d{2}$/)
+    .optional(),
+  to: z
+    .string()
+    .regex(/^\d{4}-\d{2}-\d{2}$/)
+    .optional(),
+  technicianId: z.string().optional().nullable(),
+})
+
+export async function getTimesheet(input: z.input<typeof rangeSchema>) {
+  return withAuth(
+    async (ctx): Promise<TimesheetData> => {
+      const args = rangeSchema.parse(input)
+      const [timeZone, weekStartRow] = await Promise.all([
+        workshopTimeZone(ctx.organizationId),
+        db.appSetting.findFirst({
+          where: { organizationId: ctx.organizationId, key: SETTING_KEYS.WORKBOARD_WEEK_START_DAY },
+          select: { value: true },
+        }),
+      ])
+      const weekStartDay = Number.parseInt(weekStartRow?.value ?? '1', 10) || 0
+      const now = new Date()
+      // Default: the whole of this week, from the workshop's own first day of
+      // the week, in the workshop's days. The same arithmetic as the page's
+      // "This week" preset, so the two agree on what the week is.
+      const todayStart = startOfZonedDay(now, timeZone)
+      const dow = new Date(todayStart.getTime() + 12 * 3_600_000).getUTCDay()
+      const sinceWeekStart = (dow - weekStartDay + 7) % 7
+      const weekStart = addZonedDays(todayStart, -sinceWeekStart, timeZone)
+      const range = workshopDayRange(args.from, args.to, timeZone, {
+        start: weekStart,
+        end: addZonedDays(weekStart, 6, timeZone),
+      })
+      // A window longer than a quarter is a report, not a sheet, and would
+      // pull every row the workshop has. Clamp rather than refuse.
+      const maxTo = addZonedDays(range.gte, 92, timeZone)
+      const to = range.lt > maxTo ? maxTo : range.lt
+
+      const [technicians, rows] = await Promise.all([
+        db.technician.findMany({
+          where: { organizationId: ctx.organizationId, isActive: true },
+          orderBy: { sortOrder: 'asc' },
+          select: {
+            id: true,
+            name: true,
+            color: true,
+            userId: true,
+            user: { select: { name: true } },
+          },
+        }),
+        listOrgEntries({
+          organizationId: ctx.organizationId,
+          from: range.gte,
+          to,
+          technicianId: args.technicianId ?? null,
+        }),
+      ])
+
+      const canEdit = await canEditTimeEntries(ctx)
+
+      return {
+        timeZone,
+        from: range.gte.toISOString(),
+        to: to.toISOString(),
+        fromKey: zonedDayKey(range.gte, timeZone),
+        toKey: zonedDayKey(new Date(to.getTime() - 1), timeZone),
+        // The account's name wins over the row's copy, as on the work board.
+        technicians: technicians.map((t) => ({
+          id: t.id,
+          name: t.user?.name || t.name,
+          color: t.color,
+          linked: t.userId !== null,
+        })),
+        entries: await toSheetEntries(rows),
+        canEdit,
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.TIME_TRACKING },
+      ],
+    }
+  )
+}
+
+/** Jobs a manual entry can be booked against: open ones first, then recent. */
+export async function searchTimesheetJobs(query: string) {
+  return withAuth(
+    async (ctx) => {
+      const q = query.trim()
+      const rows = await db.serviceRecord.findMany({
+        where: {
+          organizationId: ctx.organizationId,
+          ...(q
+            ? {
+                OR: [
+                  { title: { contains: q, mode: 'insensitive' } },
+                  { vehicle: { licensePlate: { contains: q, mode: 'insensitive' } } },
+                  { vehicle: { make: { contains: q, mode: 'insensitive' } } },
+                  { vehicle: { model: { contains: q, mode: 'insensitive' } } },
+                ],
+              }
+            : { status: { in: [...OPEN_SERVICE_STATUSES] } }),
+        },
+        orderBy: [{ updatedAt: 'desc' }],
+        take: 20,
+        select: {
+          id: true,
+          title: true,
+          status: true,
+          vehicle: { select: { make: true, model: true, licensePlate: true } },
+        },
+      })
+      return rows.map((r) => ({
+        id: r.id,
+        title: r.title,
+        status: r.status,
+        vehicleLabel: r.vehicle
+          ? [r.vehicle.make, r.vehicle.model].filter(Boolean).join(' ')
+          : null,
+        licensePlate: r.vehicle?.licensePlate ?? null,
+      }))
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.TIME_TRACKING },
+      ],
+    }
+  )
+}
+
+/** Wall-clock input from the dialog, "YYYY-MM-DDTHH:MM" in the workshop's zone. */
+const wallClock = z.string().regex(/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?$/)
+
+const createSchema = z.object({
+  technicianId: z.string().min(1),
+  serviceRecordId: z.string().min(1),
+  startedAt: wallClock,
+  endedAt: wallClock,
+  note: z.string().max(500).optional().nullable(),
+})
+
+export async function createTimeEntry(input: z.input<typeof createSchema>) {
+  return withAuth(
+    async (ctx) => {
+      const args = createSchema.parse(input)
+      const timeZone = await workshopTimeZone(ctx.organizationId)
+      const entry = await createManualEntry({
+        organizationId: ctx.organizationId,
+        technicianId: args.technicianId,
+        serviceRecordId: args.serviceRecordId,
+        startedAt: parseWorkshopDateTime(args.startedAt, timeZone),
+        endedAt: parseWorkshopDateTime(args.endedAt, timeZone),
+        note: args.note,
+        editedByUserId: ctx.userId,
+      })
+      const [sheet] = await toSheetEntries([entry])
+      return sheet
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.TIME_TRACKING },
+      ],
+      audit: ({ result }) => ({
+        action: 'timeEntry.create',
+        entity: 'timeEntry',
+        entityId: result.id,
+        details: auditDetails('timeEntry_create', {
+          technician: result.technicianName,
+          job: result.job.title,
+          minutes: result.durationMinutes ?? 0,
+        }),
+      }),
+    }
+  )
+}
+
+const updateSchema = z.object({
+  id: z.string().min(1),
+  startedAt: wallClock,
+  /** Null keeps a running clock running. */
+  endedAt: wallClock.nullable(),
+  note: z.string().max(500).optional().nullable(),
+})
+
+export async function updateTimeEntry(input: z.input<typeof updateSchema>) {
+  return withAuth(
+    async (ctx) => {
+      const args = updateSchema.parse(input)
+      const timeZone = await workshopTimeZone(ctx.organizationId)
+      const entry = await updateEntry({
+        organizationId: ctx.organizationId,
+        id: args.id,
+        startedAt: parseWorkshopDateTime(args.startedAt, timeZone),
+        endedAt: args.endedAt ? parseWorkshopDateTime(args.endedAt, timeZone) : null,
+        note: args.note,
+        editedByUserId: ctx.userId,
+      })
+      const [sheet] = await toSheetEntries([entry])
+      return sheet
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.TIME_TRACKING },
+      ],
+      audit: ({ result }) => ({
+        action: 'timeEntry.update',
+        entity: 'timeEntry',
+        entityId: result.id,
+        details: auditDetails('timeEntry_update', {
+          technician: result.technicianName,
+          job: result.job.title,
+          minutes: result.durationMinutes ?? 0,
+        }),
+      }),
+    }
+  )
+}
+
+/** A manager stopping a clock somebody left running: an update with "now" as the end. */
+export async function stopTimeEntry(id: string) {
+  return withAuth(
+    async (ctx) => {
+      const existing = await db.timeEntry.findFirst({
+        where: { id, organizationId: ctx.organizationId, endedAt: null },
+        select: { startedAt: true },
+      })
+      if (!existing) throw new Error('That clock is not running.')
+      const entry = await updateEntry({
+        organizationId: ctx.organizationId,
+        id,
+        startedAt: existing.startedAt,
+        endedAt: new Date(),
+        editedByUserId: ctx.userId,
+      })
+      const [sheet] = await toSheetEntries([entry])
+      return sheet
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.TIME_TRACKING },
+      ],
+      audit: ({ result }) => ({
+        action: 'timeEntry.update',
+        entity: 'timeEntry',
+        entityId: result.id,
+        details: auditDetails('timeEntry_stop', {
+          technician: result.technicianName,
+          job: result.job.title,
+          minutes: result.durationMinutes ?? 0,
+        }),
+      }),
+    }
+  )
+}
+
+export async function deleteTimeEntry(id: string) {
+  return withAuth(
+    async (ctx) => {
+      const removed = await deleteEntry(ctx.organizationId, id)
+      return { id: removed.id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.TIME_TRACKING },
+      ],
+      audit: ({ result }) => ({
+        action: 'timeEntry.delete',
+        entity: 'timeEntry',
+        entityId: result.id,
+        details: auditDetails('timeEntry_delete', { id: result.id }),
+      }),
+    }
+  )
+}

+ 337 - 0
src/features/time-tracking/Components/JobClockSection.tsx

@@ -0,0 +1,337 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import {
+  ChevronDown,
+  Loader2,
+  MoreHorizontal,
+  Pencil,
+  Plus,
+  Square,
+  Timer,
+  Trash2,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useConfirm } from '@/components/confirm-dialog'
+import { deleteTimeEntry, stopTimeEntry } from '../Actions/timesheetActions'
+import { TimeEntryDialog } from './TimeEntryDialog'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import { getJobClock, type JobClock } from '../Actions/timeClockActions'
+import {
+  entryMinutes,
+  formatElapsed,
+  formatMinutes,
+  minutesToBillableHours,
+  type SheetEntry,
+} from '../Lib/timesheet'
+import { useClockEvents, useTimeClock } from './TimeClockProvider'
+import { useTick } from '../hooks/useTick'
+import { SourceBadge } from './SourceBadge'
+
+const COLLAPSED_ROWS = 5
+
+/**
+ * Clocked time on one work order, beside the labor it will be billed as.
+ *
+ * Shows every stretch anyone has clocked here, a running one ticking. The
+ * start and stop buttons live on the dashboard's active jobs list, where a
+ * technician already is; this page is the record. "Add as labor" carries the
+ * total into a labor line the way the phone does after a stop, rounded to a
+ * billable quarter hour; the clocked record itself is never changed by it.
+ */
+export function JobClockSection({
+  serviceRecordId,
+  initial,
+  onAddLabor,
+}: {
+  serviceRecordId: string
+  initial: JobClock
+  onAddLabor: (hours: number) => void
+}) {
+  const t = useTranslations('timeTracking.job')
+  const tPage = useTranslations('timeTracking.page')
+  const { formatTime } = useFormatDate()
+  const clock = useTimeClock()
+  const confirm = useConfirm()
+  const [entries, setEntries] = useState<SheetEntry[]>(initial.entries)
+  const [expanded, setExpanded] = useState(false)
+  const [editing, setEditing] = useState<SheetEntry | null>(null)
+  const [busyId, setBusyId] = useState<string | null>(null)
+  const { canEdit, timeZone } = initial
+
+  const reload = useCallback(async () => {
+    const result = await getJobClock(serviceRecordId)
+    if (result.success && result.data) setEntries(result.data.entries)
+  }, [serviceRecordId])
+
+  // Anyone's clock on this job, from any device.
+  useClockEvents((event) => {
+    if (event.serviceRecordId === serviceRecordId) void reload()
+  })
+
+  // The provider's own start and stop resolve before the socket frame lands.
+  // Skipped on mount: the server already rendered the current list.
+  const ownRunningHere = clock.open?.serviceRecordId === serviceRecordId
+  const mounted = useRef(false)
+  useEffect(() => {
+    if (!mounted.current) {
+      mounted.current = true
+      return
+    }
+    void reload()
+  }, [ownRunningHere, reload])
+
+  const anyRunning = entries.some((e) => !e.endedAt)
+  const now = useTick(anyRunning)
+
+  const totalMinutes = useMemo(
+    () => entries.reduce((sum, e) => sum + entryMinutes(e, now), 0),
+    [entries, now]
+  )
+
+  const perTechnician = useMemo(() => {
+    const map = new Map<
+      string,
+      { name: string; color: string; minutes: number; running: boolean }
+    >()
+    for (const e of entries) {
+      const row = map.get(e.technicianId) ?? {
+        name: e.technicianName,
+        color: e.technicianColor,
+        minutes: 0,
+        running: false,
+      }
+      row.minutes += entryMinutes(e, now)
+      if (!e.endedAt) row.running = true
+      map.set(e.technicianId, row)
+    }
+    return [...map.values()].sort((a, b) => b.minutes - a.minutes)
+  }, [entries, now])
+
+  const newestFirst = useMemo(() => [...entries].reverse(), [entries])
+  const visible = expanded ? newestFirst : newestFirst.slice(0, COLLAPSED_ROWS)
+  const hidden = newestFirst.length - visible.length
+
+  const billableHours = minutesToBillableHours(totalMinutes)
+
+  // Corrections, gated on the time tracking edit permission rather than on
+  // being allowed to clock: a technician's own record is not theirs to edit.
+  const handleStop = async (entry: SheetEntry) => {
+    setBusyId(entry.id)
+    const result = await stopTimeEntry(entry.id)
+    setBusyId(null)
+    if (!result.success || !result.data) {
+      toast.error(result.error ?? tPage('loadFailed'))
+      return
+    }
+    toast.success(
+      tPage('stoppedByManager', { minutes: formatMinutes(result.data.durationMinutes ?? 0) })
+    )
+    void reload()
+  }
+
+  const handleDelete = async (entry: SheetEntry) => {
+    const ok = await confirm({
+      title: tPage('deleteTitle'),
+      description: tPage('deleteBody', {
+        duration: formatMinutes(entryMinutes(entry, now)),
+        job: entry.job.title,
+        technician: entry.technicianName,
+      }),
+      confirmLabel: tPage('deleteConfirm'),
+      destructive: true,
+    })
+    if (!ok) return
+    setBusyId(entry.id)
+    const result = await deleteTimeEntry(entry.id)
+    setBusyId(null)
+    if (!result.success) {
+      toast.error(result.error ?? tPage('loadFailed'))
+      return
+    }
+    toast.success(tPage('deleted'))
+    void reload()
+  }
+
+  return (
+    <div className="@container space-y-3 rounded-lg border p-3">
+      <div className="flex flex-wrap items-center justify-between gap-2">
+        <div className="flex items-center gap-2">
+          <Timer className="size-4 text-muted-foreground" />
+          <h3 className="text-sm font-semibold">{t('title')}</h3>
+          <Badge variant="secondary" className="font-mono tabular-nums">
+            {formatMinutes(totalMinutes)}
+          </Badge>
+          {anyRunning && (
+            <span className="relative flex size-2" aria-label={t('running')}>
+              <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60" />
+              <span className="relative inline-flex size-2 rounded-full bg-primary" />
+            </span>
+          )}
+        </div>
+        <div className="flex flex-wrap gap-1.5">
+          {totalMinutes > 0 && (
+            <Button
+              type="button"
+              size="sm"
+              variant="outline"
+              className="h-8"
+              onClick={() => onAddLabor(billableHours)}
+              title={t('addAsLaborHint', { hours: billableHours })}
+            >
+              <Plus className="size-3.5" />
+              {t('addAsLabor', { hours: billableHours })}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {perTechnician.length > 1 && (
+        <div className="flex flex-wrap gap-1.5">
+          {perTechnician.map((row) => (
+            <span
+              key={row.name}
+              className="inline-flex items-center gap-1.5 rounded-full border bg-muted/40 px-2 py-0.5 text-xs"
+            >
+              <span
+                className={cn('size-2 rounded-full', row.running && 'ring-2 ring-primary/40')}
+                style={{ backgroundColor: row.color }}
+              />
+              <span className="text-muted-foreground">{row.name}</span>
+              <span className="font-medium tabular-nums">{formatMinutes(row.minutes)}</span>
+            </span>
+          ))}
+        </div>
+      )}
+
+      {entries.length === 0 ? (
+        <p className="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
+          {t('empty')}
+        </p>
+      ) : (
+        <ul className="divide-y rounded-md border">
+          {visible.map((e) => {
+            const running = !e.endedAt
+            const minutes = entryMinutes(e, now)
+            return (
+              <li
+                key={e.id}
+                className={cn(
+                  'flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-xs',
+                  running && 'bg-primary/5'
+                )}
+              >
+                <span
+                  className="size-2 shrink-0 rounded-full"
+                  style={{ backgroundColor: e.technicianColor }}
+                  aria-hidden
+                />
+                <span className="min-w-0 flex-1 truncate font-medium">{e.technicianName}</span>
+                <span className="tabular-nums text-muted-foreground">
+                  {formatTime(e.startedAt)}
+                  {' – '}
+                  {running ? (
+                    <span className="text-primary">{t('running')}</span>
+                  ) : (
+                    formatTime(e.endedAt as string)
+                  )}
+                </span>
+                <span className="w-16 text-right font-mono tabular-nums">
+                  {running
+                    ? formatElapsed(now.getTime() - new Date(e.startedAt).getTime())
+                    : formatMinutes(minutes)}
+                </span>
+                <SourceBadge source={e.source} editedByName={e.editedByName} />
+                {canEdit && (
+                  <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="icon"
+                        className="size-6 shrink-0"
+                        aria-label={tPage('table.actions')}
+                        disabled={busyId === e.id}
+                      >
+                        {busyId === e.id ? (
+                          <Loader2 className="size-3.5 animate-spin" />
+                        ) : (
+                          <MoreHorizontal className="size-3.5" />
+                        )}
+                      </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent align="end">
+                      {running && (
+                        <DropdownMenuItem onClick={() => void handleStop(e)}>
+                          <Square className="size-3.5 fill-current" />
+                          {tPage('table.stop')}
+                        </DropdownMenuItem>
+                      )}
+                      <DropdownMenuItem onClick={() => setEditing(e)}>
+                        <Pencil className="size-3.5" />
+                        {tPage('table.edit')}
+                      </DropdownMenuItem>
+                      <DropdownMenuSeparator />
+                      <DropdownMenuItem variant="destructive" onClick={() => void handleDelete(e)}>
+                        <Trash2 className="size-3.5" />
+                        {tPage('table.delete')}
+                      </DropdownMenuItem>
+                    </DropdownMenuContent>
+                  </DropdownMenu>
+                )}
+                {e.note && (
+                  <span className="basis-full truncate text-muted-foreground" title={e.note}>
+                    {e.note}
+                  </span>
+                )}
+              </li>
+            )
+          })}
+        </ul>
+      )}
+
+      {(hidden > 0 || expanded) && (
+        <div className="flex items-center justify-between">
+          <button
+            type="button"
+            onClick={() => setExpanded((v) => !v)}
+            className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+          >
+            <ChevronDown
+              className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
+            />
+            {expanded ? t('showFewer') : t('showAll', { count: hidden })}
+          </button>
+          <Link href="/timesheets" className="text-xs text-muted-foreground hover:text-foreground">
+            {t('openTimesheets')}
+          </Link>
+        </div>
+      )}
+
+      {canEdit && (
+        <TimeEntryDialog
+          open={editing !== null}
+          onOpenChange={(open) => {
+            if (!open) setEditing(null)
+          }}
+          timeZone={timeZone}
+          technicians={[]}
+          entry={editing}
+          onSaved={() => void reload()}
+        />
+      )}
+    </div>
+  )
+}

+ 71 - 0
src/features/time-tracking/Components/RunningClockPill.tsx

@@ -0,0 +1,71 @@
+'use client'
+
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Loader2, Square } from 'lucide-react'
+import { useTimeClock } from './TimeClockProvider'
+import { useTick } from '../hooks/useTick'
+import { formatElapsed } from '../Lib/timesheet'
+import { cn } from '@/lib/utils'
+
+/**
+ * The running clock, in the header of every page.
+ *
+ * The phone keeps a bar above its tab strip for the same reason: a clock
+ * that is only visible on the job's own page gets forgotten the moment
+ * somebody navigates away, and a forgotten clock is the one error the whole
+ * feature exists to prevent. Nothing renders while nothing runs.
+ */
+export function RunningClockPill() {
+  const t = useTranslations('timeTracking.clock')
+  const { open, busy, stop } = useTimeClock()
+  const now = useTick(open !== null)
+  if (!open) return null
+
+  const href = open.vehicleId
+    ? `/vehicles/${open.vehicleId}/service/${open.serviceRecordId}`
+    : `/sales/${open.serviceRecordId}`
+  const elapsed = formatElapsed(now.getTime() - new Date(open.startedAt).getTime())
+
+  return (
+    <div
+      className={cn(
+        'flex h-8 items-center overflow-hidden rounded-md border border-primary/40 bg-primary/10 text-sm',
+        'shadow-[inset_0_1px_0_rgb(255_255_255/0.25)]'
+      )}
+      role="status"
+      aria-live="off"
+    >
+      <Link
+        href={href}
+        className="flex h-full min-w-0 items-center gap-2 pl-2.5 pr-2 hover:bg-primary/10"
+        title={t('runningOn', { job: open.jobTitle })}
+      >
+        <span className="relative flex size-2 shrink-0">
+          <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60" />
+          <span className="relative inline-flex size-2 rounded-full bg-primary" />
+        </span>
+        <span className="font-mono text-[13px] font-semibold tabular-nums text-primary">
+          {elapsed}
+        </span>
+        <span className="hidden max-w-40 truncate text-xs text-muted-foreground lg:inline">
+          {open.jobTitle}
+        </span>
+      </Link>
+      <button
+        type="button"
+        onClick={() => void stop()}
+        disabled={busy}
+        aria-label={t('stop')}
+        title={t('stop')}
+        className="flex h-full cursor-pointer items-center border-l border-primary/30 px-2 text-primary transition-colors hover:bg-primary/15 disabled:opacity-50"
+      >
+        {busy ? (
+          <Loader2 className="size-3.5 animate-spin" />
+        ) : (
+          <Square className="size-3 fill-current" />
+        )}
+      </button>
+    </div>
+  )
+}

+ 41 - 0
src/features/time-tracking/Components/SourceBadge.tsx

@@ -0,0 +1,41 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { PencilLine, Smartphone, Monitor, Keyboard } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+/**
+ * Where an entry came from, and whether a person has touched it since.
+ *
+ * A figure the app measured and a figure somebody typed in look identical as
+ * numbers; the badge is what stops a corrected sheet from passing as a
+ * recorded one.
+ */
+export function SourceBadge({
+  source,
+  editedByName,
+  className,
+}: {
+  source: string
+  editedByName: string | null
+  className?: string
+}) {
+  const t = useTranslations('timeTracking.source')
+  const Icon = source === 'manual' ? Keyboard : source === 'web' ? Monitor : Smartphone
+  const label = source === 'manual' ? t('manual') : source === 'web' ? t('web') : t('app')
+  return (
+    <span
+      className={cn(
+        'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground',
+        source === 'manual' &&
+          'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
+        className
+      )}
+      title={editedByName ? t('editedBy', { name: editedByName }) : label}
+    >
+      <Icon className="size-3" />
+      {label}
+      {editedByName && source !== 'manual' && <PencilLine className="size-3" />}
+    </span>
+  )
+}

+ 189 - 0
src/features/time-tracking/Components/TimeClockProvider.tsx

@@ -0,0 +1,189 @@
+'use client'
+
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  type ReactNode,
+} from 'react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { getMyClock, startMyClock, stopMyClock, type MyClock } from '../Actions/timeClockActions'
+import type { ClockEvent } from '../Lib/timeEntries'
+import { formatMinutes } from '../Lib/timesheet'
+
+/**
+ * The signed-in person's clock, held once for the whole app.
+ *
+ * The header pill, the work order panel and the timesheet all read the same
+ * state, and one socket keeps it honest: a clock started from the phone
+ * shows up in the browser without a reload, and the other way round.
+ */
+
+type Listener = (event: ClockEvent) => void
+
+interface TimeClockValue {
+  /** Whether the signed-in account is linked to a technician row here. */
+  isTechnician: boolean
+  technicianIds: string[]
+  open: MyClock['open']
+  /** True while a start or stop is in flight. */
+  busy: boolean
+  start: (serviceRecordId: string) => Promise<boolean>
+  stop: () => Promise<boolean>
+  refresh: () => Promise<void>
+  /** Every clock change in the workshop, anyone's. */
+  subscribe: (listener: Listener) => () => void
+}
+
+const TimeClockContext = createContext<TimeClockValue | null>(null)
+
+const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000]
+
+export function TimeClockProvider({
+  technicianIds,
+  children,
+}: {
+  technicianIds: string[]
+  children: ReactNode
+}) {
+  const t = useTranslations('timeTracking.clock')
+  const [open, setOpen] = useState<MyClock['open']>(null)
+  const [busy, setBusy] = useState(false)
+  const listeners = useRef(new Set<Listener>())
+  const idsRef = useRef(technicianIds)
+  idsRef.current = technicianIds
+  const isTechnician = technicianIds.length > 0
+
+  const refresh = useCallback(async () => {
+    if (idsRef.current.length === 0) return
+    const result = await getMyClock()
+    if (result.success && result.data) setOpen(result.data.open)
+  }, [])
+
+  useEffect(() => {
+    void refresh()
+  }, [refresh])
+
+  // One socket for the life of the app shell. Mirrors the work board hook:
+  // liveness is a per-run closure so a StrictMode remount cannot leave two
+  // sockets each reconnecting on the other's behalf.
+  useEffect(() => {
+    let alive = true
+    let attempt = 0
+    let timer: ReturnType<typeof setTimeout> | undefined
+    let ws: WebSocket | null = null
+
+    const connect = () => {
+      if (!alive) return
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      ws = new WebSocket(`${protocol}//${window.location.host}/api/protected/ws`)
+      ws.onopen = () => {
+        attempt = 0
+        // Anything that changed while the socket was down.
+        void refresh()
+      }
+      ws.onmessage = (event) => {
+        try {
+          const msg = JSON.parse(event.data)
+          if (msg.type !== 'workboard') return
+          const data = msg.data as Partial<ClockEvent>
+          if (data.type !== 'clock_started' && data.type !== 'clock_stopped') return
+          const clockEvent = data as ClockEvent
+          if (idsRef.current.includes(clockEvent.technicianId)) void refresh()
+          for (const listener of listeners.current) listener(clockEvent)
+        } catch {
+          /* a frame we do not understand is not worth a broken clock */
+        }
+      }
+      ws.onclose = () => {
+        if (!alive) return
+        const delay = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]
+        attempt++
+        timer = setTimeout(connect, delay)
+      }
+      ws.onerror = () => ws?.close()
+    }
+
+    connect()
+    return () => {
+      alive = false
+      if (timer) clearTimeout(timer)
+      ws?.close()
+    }
+  }, [refresh])
+
+  const subscribe = useCallback((listener: Listener) => {
+    listeners.current.add(listener)
+    return () => {
+      listeners.current.delete(listener)
+    }
+  }, [])
+
+  const start = useCallback(
+    async (serviceRecordId: string) => {
+      if (busy) return false
+      setBusy(true)
+      try {
+        const result = await startMyClock({ serviceRecordId })
+        if (!result.success || !result.data) {
+          toast.error(result.error || t('startFailed'))
+          return false
+        }
+        if (result.data.closed) {
+          toast(t('switched', { minutes: formatMinutes(result.data.closed.minutes ?? 0) }))
+        }
+        await refresh()
+        return true
+      } finally {
+        setBusy(false)
+      }
+    },
+    [busy, refresh, t]
+  )
+
+  const stop = useCallback(async () => {
+    if (busy) return false
+    setBusy(true)
+    try {
+      const result = await stopMyClock()
+      if (!result.success || !result.data) {
+        toast.error(result.error || t('stopFailed'))
+        await refresh()
+        return false
+      }
+      toast.success(t('stopped', { minutes: formatMinutes(result.data.minutes) }))
+      setOpen(null)
+      return true
+    } finally {
+      setBusy(false)
+    }
+  }, [busy, refresh, t])
+
+  const value = useMemo<TimeClockValue>(
+    () => ({ isTechnician, technicianIds, open, busy, start, stop, refresh, subscribe }),
+    [isTechnician, technicianIds, open, busy, start, stop, refresh, subscribe]
+  )
+
+  return <TimeClockContext.Provider value={value}>{children}</TimeClockContext.Provider>
+}
+
+export function useTimeClock(): TimeClockValue {
+  const ctx = useContext(TimeClockContext)
+  if (!ctx) {
+    throw new Error('useTimeClock must be used inside TimeClockProvider')
+  }
+  return ctx
+}
+
+/** Re-run `handler` on every clock change in the workshop. */
+export function useClockEvents(handler: Listener) {
+  const { subscribe } = useTimeClock()
+  const ref = useRef(handler)
+  ref.current = handler
+  useEffect(() => subscribe((event) => ref.current(event)), [subscribe])
+}

+ 361 - 0
src/features/time-tracking/Components/TimeEntryDialog.tsx

@@ -0,0 +1,361 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { zonedParts } from '@/lib/timezone'
+import { cn } from '@/lib/utils'
+import { createTimeEntry, searchTimesheetJobs, updateTimeEntry } from '../Actions/timesheetActions'
+import {
+  formatMinutes,
+  minutesBetween,
+  type SheetEntry,
+  type SheetTechnician,
+} from '../Lib/timesheet'
+
+/** ISO instant to the "YYYY-MM-DDTHH:MM" a datetime-local input wants, on the workshop clock. */
+function toWallClock(iso: string, timeZone: string): string {
+  const p = zonedParts(new Date(iso), timeZone)
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`
+}
+
+type JobOption = {
+  id: string
+  title: string
+  status: string
+  vehicleLabel: string | null
+  licensePlate: string | null
+}
+
+/**
+ * Add or correct one stretch of time.
+ *
+ * Every time here is the workshop's wall clock, whatever the manager's
+ * browser thinks the hour is; the server parses it in the workshop's zone.
+ * Correcting keeps the entry's technician and job fixed: moving hours to
+ * another person or job is a delete and an add, and should look like one
+ * in the audit log.
+ */
+export function TimeEntryDialog({
+  open,
+  onOpenChange,
+  timeZone,
+  technicians,
+  entry,
+  defaults,
+  onSaved,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  timeZone: string
+  technicians: SheetTechnician[]
+  /** Present when correcting; absent when adding. */
+  entry?: SheetEntry | null
+  defaults?: { technicianId?: string; dayKey?: string }
+  onSaved: (saved: SheetEntry) => void
+}) {
+  const t = useTranslations('timeTracking.dialog')
+  const editing = Boolean(entry)
+  const [technicianId, setTechnicianId] = useState('')
+  const [job, setJob] = useState<JobOption | null>(null)
+  const [startedAt, setStartedAt] = useState('')
+  const [endedAt, setEndedAt] = useState('')
+  const [note, setNote] = useState('')
+  const [saving, setSaving] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    if (entry) {
+      setTechnicianId(entry.technicianId)
+      setJob({
+        id: entry.job.id,
+        title: entry.job.title,
+        status: entry.job.status,
+        vehicleLabel: entry.job.vehicleLabel,
+        licensePlate: entry.job.licensePlate,
+      })
+      setStartedAt(toWallClock(entry.startedAt, timeZone))
+      setEndedAt(entry.endedAt ? toWallClock(entry.endedAt, timeZone) : '')
+      setNote(entry.note ?? '')
+    } else {
+      setTechnicianId(defaults?.technicianId ?? technicians[0]?.id ?? '')
+      setJob(null)
+      const day = defaults?.dayKey ?? toWallClock(new Date().toISOString(), timeZone).slice(0, 10)
+      setStartedAt(`${day}T08:00`)
+      setEndedAt(`${day}T16:00`)
+      setNote('')
+    }
+  }, [open, entry, defaults, technicians, timeZone])
+
+  const duration = useMemo(() => {
+    if (!startedAt || !endedAt) return null
+    const a = new Date(startedAt)
+    const b = new Date(endedAt)
+    if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null
+    // Both parsed in the browser's zone, which cancels out for a difference.
+    return minutesBetween(a, b)
+  }, [startedAt, endedAt])
+
+  const wasRunning = entry ? !entry.endedAt : false
+  const endInvalid = duration !== null && duration <= 0
+  const canSave =
+    !saving &&
+    startedAt !== '' &&
+    (editing ? entry !== null : technicianId !== '' && job !== null) &&
+    (wasRunning ? endedAt === '' || !endInvalid : endedAt !== '' && !endInvalid)
+
+  const submit = async () => {
+    if (!canSave) return
+    setSaving(true)
+    try {
+      const result = editing
+        ? await updateTimeEntry({
+            id: (entry as SheetEntry).id,
+            startedAt,
+            endedAt: endedAt || null,
+            note,
+          })
+        : await createTimeEntry({
+            technicianId,
+            serviceRecordId: (job as JobOption).id,
+            startedAt,
+            endedAt,
+            note,
+          })
+      if (!result.success || !result.data) {
+        toast.error(result.error || t('saveFailed'))
+        return
+      }
+      toast.success(editing ? t('updated') : t('added'))
+      onSaved(result.data)
+      onOpenChange(false)
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{editing ? t('editTitle') : t('addTitle')}</DialogTitle>
+          <DialogDescription>
+            {editing ? t('editDescription') : t('addDescription')}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          <div className="grid gap-1.5">
+            <Label htmlFor="te-technician">{t('technician')}</Label>
+            {editing ? (
+              <Input id="te-technician" value={entry?.technicianName ?? ''} disabled />
+            ) : (
+              <Select value={technicianId} onValueChange={setTechnicianId}>
+                <SelectTrigger id="te-technician" className="w-full">
+                  <SelectValue placeholder={t('pickTechnician')} />
+                </SelectTrigger>
+                <SelectContent>
+                  {technicians.map((tech) => (
+                    <SelectItem key={tech.id} value={tech.id}>
+                      <span className="flex items-center gap-2">
+                        <span
+                          className="size-2 rounded-full"
+                          style={{ backgroundColor: tech.color }}
+                        />
+                        {tech.name}
+                      </span>
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            )}
+          </div>
+
+          <div className="grid gap-1.5">
+            <Label>{t('job')}</Label>
+            {editing ? (
+              <Input value={jobLabel(job)} disabled />
+            ) : (
+              <JobPicker value={job} onChange={setJob} />
+            )}
+          </div>
+
+          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+            <div className="grid gap-1.5">
+              <Label htmlFor="te-start">{t('start')}</Label>
+              <Input
+                id="te-start"
+                type="datetime-local"
+                value={startedAt}
+                onChange={(e) => setStartedAt(e.target.value)}
+                required
+              />
+            </div>
+            <div className="grid gap-1.5">
+              <Label htmlFor="te-end">{t('end')}</Label>
+              <Input
+                id="te-end"
+                type="datetime-local"
+                value={endedAt}
+                min={startedAt || undefined}
+                onChange={(e) => setEndedAt(e.target.value)}
+                aria-invalid={endInvalid || undefined}
+                placeholder={wasRunning ? t('stillRunning') : undefined}
+              />
+            </div>
+          </div>
+
+          <p className="text-xs text-muted-foreground">
+            {endInvalid
+              ? t('endBeforeStart')
+              : duration !== null
+                ? t('duration', { duration: formatMinutes(duration) })
+                : wasRunning
+                  ? t('leaveEndEmpty')
+                  : t('timesInWorkshopZone', { zone: timeZone })}
+          </p>
+
+          <div className="grid gap-1.5">
+            <Label htmlFor="te-note">{t('note')}</Label>
+            <Textarea
+              id="te-note"
+              value={note}
+              onChange={(e) => setNote(e.target.value)}
+              rows={2}
+              maxLength={500}
+              placeholder={t('notePlaceholder')}
+            />
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+            {t('cancel')}
+          </Button>
+          <Button type="button" onClick={() => void submit()} disabled={!canSave}>
+            {saving && <Loader2 className="size-4 animate-spin" />}
+            {editing ? t('save') : t('add')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function jobLabel(job: JobOption | null): string {
+  if (!job) return ''
+  const bits = [job.title, job.licensePlate ?? job.vehicleLabel].filter(Boolean)
+  return bits.join(' · ')
+}
+
+/** Searches the workshop's jobs as the manager types; open jobs before a query. */
+function JobPicker({
+  value,
+  onChange,
+}: {
+  value: JobOption | null
+  onChange: (job: JobOption | null) => void
+}) {
+  const t = useTranslations('timeTracking.dialog')
+  const [open, setOpen] = useState(false)
+  const [query, setQuery] = useState('')
+  const [options, setOptions] = useState<JobOption[]>([])
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    let cancelled = false
+    setLoading(true)
+    const timer = setTimeout(async () => {
+      const result = await searchTimesheetJobs(query)
+      if (cancelled) return
+      setOptions(result.success && result.data ? result.data : [])
+      setLoading(false)
+    }, 200)
+    return () => {
+      cancelled = true
+      clearTimeout(timer)
+    }
+  }, [open, query])
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          type="button"
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          className="w-full justify-between font-normal"
+        >
+          <span className={cn('truncate', !value && 'text-muted-foreground')}>
+            {value ? jobLabel(value) : t('pickJob')}
+          </span>
+          <ChevronsUpDown className="size-4 shrink-0 opacity-50" />
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
+        <Command shouldFilter={false}>
+          <CommandInput placeholder={t('searchJobs')} value={query} onValueChange={setQuery} />
+          <CommandList>
+            <CommandEmpty>{loading ? t('searching') : t('noJobs')}</CommandEmpty>
+            <CommandGroup>
+              {options.map((job) => (
+                <CommandItem
+                  key={job.id}
+                  value={job.id}
+                  onSelect={() => {
+                    onChange(job)
+                    setOpen(false)
+                  }}
+                >
+                  <Check
+                    className={cn('size-4', value?.id === job.id ? 'opacity-100' : 'opacity-0')}
+                  />
+                  <span className="flex min-w-0 flex-1 flex-col">
+                    <span className="truncate">{job.title}</span>
+                    {(job.licensePlate || job.vehicleLabel) && (
+                      <span className="truncate text-xs text-muted-foreground">
+                        {[job.licensePlate, job.vehicleLabel].filter(Boolean).join(' · ')}
+                      </span>
+                    )}
+                  </span>
+                </CommandItem>
+              ))}
+            </CommandGroup>
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 18 - 0
src/features/time-tracking/Lib/canEdit.ts

@@ -0,0 +1,18 @@
+import { getCachedMembership } from '@/lib/cached-session'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import type { AuthContext } from '@/lib/with-auth'
+
+/**
+ * Whether this account may correct clocked time: add, edit, stop or delete
+ * entries. Admins may; everyone else needs the grant on their role. Clocking
+ * oneself is deliberately not covered here, so a technician's own permission
+ * never extends to rewriting their record.
+ */
+export async function canEditTimeEntries(ctx: Pick<AuthContext, 'userId' | 'isAdmin'>) {
+  if (ctx.isAdmin) return true
+  const membership = await getCachedMembership(ctx.userId)
+  return hasPermission(membership?.customRole?.permissions ?? [], {
+    action: PermissionAction.UPDATE,
+    subject: PermissionSubject.TIME_TRACKING,
+  })
+}

+ 68 - 0
src/features/time-tracking/Lib/serialize.ts

@@ -0,0 +1,68 @@
+import { db } from '@/lib/db'
+import type { SheetEntry } from './timesheet'
+
+/** A row read with `entrySelect` from ./timeEntries. */
+export interface EntryRow {
+  id: string
+  startedAt: Date
+  endedAt: Date | null
+  durationMinutes: number | null
+  note: string | null
+  source: string
+  editedAt: Date | null
+  editedByUserId: string | null
+  technicianId: string
+  technician: { id: string; name: string; color: string }
+  serviceRecord: {
+    id: string
+    title: string
+    status: string
+    vehicleId: string | null
+    vehicle: { make: string; model: string; licensePlate: string | null } | null
+  }
+}
+
+/**
+ * Rows to what the browser reads.
+ *
+ * Instants become ISO strings, the vehicle collapses to one label, and the
+ * account that last corrected an entry is named rather than left as an id.
+ * The names come from one query for the whole batch; a sheet of a hundred
+ * rows must not cost a hundred lookups.
+ */
+export async function toSheetEntries(rows: EntryRow[]): Promise<SheetEntry[]> {
+  const editorIds = [...new Set(rows.map((r) => r.editedByUserId).filter(Boolean))] as string[]
+  const editors = editorIds.length
+    ? await db.user.findMany({
+        where: { id: { in: editorIds } },
+        select: { id: true, name: true, email: true },
+      })
+    : []
+  const editorName = new Map(editors.map((u) => [u.id, u.name || u.email]))
+
+  return rows.map((r) => {
+    const v = r.serviceRecord.vehicle
+    const vehicleLabel = v ? [v.make, v.model].filter(Boolean).join(' ') || null : null
+    return {
+      id: r.id,
+      startedAt: r.startedAt.toISOString(),
+      endedAt: r.endedAt ? r.endedAt.toISOString() : null,
+      durationMinutes: r.durationMinutes,
+      note: r.note,
+      source: r.source,
+      editedAt: r.editedAt ? r.editedAt.toISOString() : null,
+      editedByName: r.editedByUserId ? (editorName.get(r.editedByUserId) ?? null) : null,
+      technicianId: r.technicianId,
+      technicianName: r.technician.name,
+      technicianColor: r.technician.color,
+      job: {
+        id: r.serviceRecord.id,
+        title: r.serviceRecord.title,
+        status: r.serviceRecord.status,
+        vehicleId: r.serviceRecord.vehicleId,
+        vehicleLabel,
+        licensePlate: v?.licensePlate ?? null,
+      },
+    }
+  })
+}

+ 290 - 25
src/features/time-tracking/Lib/timeEntries.ts

@@ -1,4 +1,5 @@
 import { db } from '@/lib/db'
+import { notificationBus } from '@/lib/notification-bus'
 
 /**
  * Clocking a technician on and off a job.
@@ -19,6 +20,45 @@ export class TimeEntryError extends Error {
   }
 }
 
+/**
+ * What the web is told when a clock changes.
+ *
+ * Goes out on the work board channel, which already fans out to every
+ * signed-in browser in the workshop, so the desk sees a clock start the
+ * moment the bay taps it. Only ids travel: the listener refetches what it
+ * needs, which keeps a stale frame from ever overwriting a fresher read.
+ */
+export type ClockEvent = {
+  type: 'clock_started' | 'clock_stopped'
+  organizationId: string
+  technicianId: string
+  serviceRecordId: string
+  entryId: string
+}
+
+function announce(event: ClockEvent) {
+  notificationBus.emit('workboard', event)
+}
+
+/**
+ * Every technician row the signed-in account owns in this workshop.
+ *
+ * The web has no bearer token carrying these the way the app does, so the
+ * clock buttons resolve them per request. Inactive rows are left out: a
+ * deactivated technician keeps their history but cannot clock.
+ */
+export async function technicianIdsForUser(
+  organizationId: string,
+  userId: string
+): Promise<string[]> {
+  const rows = await db.technician.findMany({
+    where: { organizationId, userId, isActive: true },
+    orderBy: { sortOrder: 'asc' },
+    select: { id: true },
+  })
+  return rows.map((r) => r.id)
+}
+
 export function durationMinutes(startedAt: Date, endedAt: Date): number {
   // Rounded, not truncated: a 59-second job is a minute of someone's day, and
   // truncating would make a shift of short jobs quietly bill as less than it
@@ -42,7 +82,7 @@ export async function getOpenEntry(organizationId: string, technicianIds: string
       startedAt: true,
       technicianId: true,
       serviceRecordId: true,
-      serviceRecord: { select: { id: true, title: true, status: true } },
+      serviceRecord: { select: { id: true, title: true, status: true, vehicleId: true } },
     },
   })
 }
@@ -98,35 +138,57 @@ export async function startEntry(args: {
 
   const now = new Date()
 
-  return db.$transaction(async (tx) => {
-    let closed = null
-    if (open) {
-      closed = await tx.timeEntry.update({
-        where: { id: open.id },
-        data: { endedAt: now, durationMinutes: durationMinutes(open.startedAt, now) },
-        select: { id: true, serviceRecordId: true, durationMinutes: true },
+  return db
+    .$transaction(async (tx) => {
+      let closed = null
+      if (open) {
+        closed = await tx.timeEntry.update({
+          where: { id: open.id },
+          data: { endedAt: now, durationMinutes: durationMinutes(open.startedAt, now) },
+          select: { id: true, serviceRecordId: true, durationMinutes: true },
+        })
+      }
+
+      const entry = await tx.timeEntry.create({
+        data: {
+          organizationId,
+          technicianId,
+          serviceRecordId,
+          startedAt: now,
+          source: args.source ?? 'app',
+        },
+        select: {
+          id: true,
+          startedAt: true,
+          technicianId: true,
+          serviceRecordId: true,
+          serviceRecord: { select: { id: true, title: true, status: true } },
+        },
       })
-    }
 
-    const entry = await tx.timeEntry.create({
-      data: {
+      return { entry, closed }
+    })
+    .then((result) => {
+      // After the commit, never inside it: a listener that refetches on the
+      // event must find the rows already there.
+      if (result.closed && open) {
+        announce({
+          type: 'clock_stopped',
+          organizationId,
+          technicianId: open.technicianId,
+          serviceRecordId: result.closed.serviceRecordId,
+          entryId: result.closed.id,
+        })
+      }
+      announce({
+        type: 'clock_started',
         organizationId,
         technicianId,
         serviceRecordId,
-        startedAt: now,
-        source: args.source ?? 'app',
-      },
-      select: {
-        id: true,
-        startedAt: true,
-        technicianId: true,
-        serviceRecordId: true,
-        serviceRecord: { select: { id: true, title: true, status: true } },
-      },
+        entryId: result.entry.id,
+      })
+      return result
     })
-
-    return { entry, closed }
-  })
 }
 
 /** Stop whatever is running. Idempotent: stopping nothing is not an error the app should have to handle twice. */
@@ -141,7 +203,7 @@ export async function stopEntry(args: {
   }
 
   const now = new Date()
-  return db.timeEntry.update({
+  const entry = await db.timeEntry.update({
     where: { id: open.id },
     data: {
       endedAt: now,
@@ -157,6 +219,14 @@ export async function stopEntry(args: {
       note: true,
     },
   })
+  announce({
+    type: 'clock_stopped',
+    organizationId: args.organizationId,
+    technicianId: open.technicianId,
+    serviceRecordId: entry.serviceRecordId,
+    entryId: entry.id,
+  })
+  return entry
 }
 
 /**
@@ -197,3 +267,198 @@ export async function listEntries(args: {
     },
   })
 }
+
+// ─── What the desk sees ─────────────────────────────────────────────────────
+
+/** The shape the timesheet and the work order panel both read. */
+export const entrySelect = {
+  id: true,
+  startedAt: true,
+  endedAt: true,
+  durationMinutes: true,
+  note: true,
+  source: true,
+  editedAt: true,
+  editedByUserId: true,
+  technicianId: true,
+  technician: { select: { id: true, name: true, color: true } },
+  serviceRecord: {
+    select: {
+      id: true,
+      title: true,
+      status: true,
+      vehicleId: true,
+      vehicle: { select: { make: true, model: true, licensePlate: true } },
+    },
+  },
+} as const
+
+/**
+ * Every entry in the workshop that touches a window, including one that
+ * started before it and is still running: a night shift that crossed
+ * midnight belongs on both days' sheets, and a clock nobody stopped belongs
+ * on today's until somebody does.
+ */
+export async function listOrgEntries(args: {
+  organizationId: string
+  from: Date
+  to: Date
+  technicianId?: string | null
+}) {
+  return db.timeEntry.findMany({
+    where: {
+      organizationId: args.organizationId,
+      ...(args.technicianId ? { technicianId: args.technicianId } : {}),
+      startedAt: { lt: args.to },
+      OR: [{ endedAt: null }, { endedAt: { gte: args.from } }],
+    },
+    orderBy: { startedAt: 'asc' },
+    select: entrySelect,
+  })
+}
+
+/** Everyone clocked in right now, newest start first. */
+export async function listRunningEntries(organizationId: string) {
+  return db.timeEntry.findMany({
+    where: { organizationId, endedAt: null },
+    orderBy: { startedAt: 'desc' },
+    select: entrySelect,
+  })
+}
+
+/** Every stretch anyone has clocked on one job, oldest first. */
+export async function listJobEntries(organizationId: string, serviceRecordId: string) {
+  return db.timeEntry.findMany({
+    where: { organizationId, serviceRecordId },
+    orderBy: { startedAt: 'asc' },
+    select: entrySelect,
+  })
+}
+
+// ─── Corrections ────────────────────────────────────────────────────────────
+
+/**
+ * A stretch typed in by hand: a technician who forgot to clock in, or a
+ * day the phone was flat. Marked `manual` so the sheet never passes it off
+ * as something a clock measured.
+ */
+export async function createManualEntry(args: {
+  organizationId: string
+  technicianId: string
+  serviceRecordId: string
+  startedAt: Date
+  endedAt: Date
+  note?: string | null
+  editedByUserId: string
+}) {
+  if (args.endedAt.getTime() <= args.startedAt.getTime()) {
+    throw new TimeEntryError('invalid_range', 'The clock cannot stop before it starts.')
+  }
+  const [technician, job] = await Promise.all([
+    db.technician.findFirst({
+      where: { id: args.technicianId, organizationId: args.organizationId },
+      select: { id: true },
+    }),
+    db.serviceRecord.findFirst({
+      where: { id: args.serviceRecordId, organizationId: args.organizationId },
+      select: { id: true },
+    }),
+  ])
+  if (!technician || !job) {
+    throw new TimeEntryError('job_not_found', 'That job or technician is not in this workshop.')
+  }
+  return db.timeEntry.create({
+    data: {
+      organizationId: args.organizationId,
+      technicianId: args.technicianId,
+      serviceRecordId: args.serviceRecordId,
+      startedAt: args.startedAt,
+      endedAt: args.endedAt,
+      durationMinutes: durationMinutes(args.startedAt, args.endedAt),
+      note: args.note?.trim() || null,
+      source: 'manual',
+      editedAt: new Date(),
+      editedByUserId: args.editedByUserId,
+    },
+    select: entrySelect,
+  })
+}
+
+/**
+ * Correct a recorded stretch after the fact.
+ *
+ * Keeps the source the clock gave it and stamps who changed it, so the
+ * sheet can show "recorded by the app, adjusted by Kari" rather than
+ * quietly presenting an edited figure as measured. Giving a running entry
+ * an end is how a manager stops a clock somebody left on.
+ */
+export async function updateEntry(args: {
+  organizationId: string
+  id: string
+  startedAt: Date
+  endedAt: Date | null
+  note?: string | null
+  editedByUserId: string
+}) {
+  const existing = await db.timeEntry.findFirst({
+    where: { id: args.id, organizationId: args.organizationId },
+    select: { id: true, technicianId: true, serviceRecordId: true, endedAt: true },
+  })
+  if (!existing) {
+    throw new TimeEntryError('job_not_found', 'That entry no longer exists.')
+  }
+  if (args.endedAt && args.endedAt.getTime() <= args.startedAt.getTime()) {
+    throw new TimeEntryError('invalid_range', 'The clock cannot stop before it starts.')
+  }
+  if (!args.endedAt && args.startedAt.getTime() > Date.now()) {
+    throw new TimeEntryError('invalid_range', 'A running clock cannot start in the future.')
+  }
+  // Re-opening an entry would leave two clocks running for one person if
+  // they have since started another, so a closed stretch stays closed.
+  if (existing.endedAt && !args.endedAt) {
+    throw new TimeEntryError('invalid_range', 'A stopped entry cannot be restarted.')
+  }
+  const entry = await db.timeEntry.update({
+    where: { id: existing.id },
+    data: {
+      startedAt: args.startedAt,
+      endedAt: args.endedAt,
+      durationMinutes: args.endedAt ? durationMinutes(args.startedAt, args.endedAt) : null,
+      note: args.note?.trim() || null,
+      editedAt: new Date(),
+      editedByUserId: args.editedByUserId,
+    },
+    select: entrySelect,
+  })
+  if (!existing.endedAt && args.endedAt) {
+    announce({
+      type: 'clock_stopped',
+      organizationId: args.organizationId,
+      technicianId: existing.technicianId,
+      serviceRecordId: existing.serviceRecordId,
+      entryId: existing.id,
+    })
+  }
+  return entry
+}
+
+export async function deleteEntry(organizationId: string, id: string) {
+  const existing = await db.timeEntry.findFirst({
+    where: { id, organizationId },
+    select: { id: true, technicianId: true, serviceRecordId: true, endedAt: true },
+  })
+  if (!existing) {
+    throw new TimeEntryError('job_not_found', 'That entry no longer exists.')
+  }
+  await db.timeEntry.delete({ where: { id: existing.id } })
+  if (!existing.endedAt) {
+    announce({
+      type: 'clock_stopped',
+      organizationId,
+      technicianId: existing.technicianId,
+      serviceRecordId: existing.serviceRecordId,
+      entryId: existing.id,
+    })
+  }
+  return existing
+}

+ 56 - 0
src/features/time-tracking/Lib/timesheet-csv.ts

@@ -0,0 +1,56 @@
+import { zonedParts } from '@/lib/timezone'
+import { entryMinutes, formatMinutes, minutesToDecimalHours, type SheetEntry } from './timesheet'
+
+/**
+ * The sheet as a spreadsheet: one row per stretch, times on the workshop's
+ * clock, hours both as h:mm and as a decimal so payroll can sum a column.
+ */
+export function timesheetCsv(
+  entries: SheetEntry[],
+  timeZone: string,
+  now: Date,
+  headers: string[]
+): string {
+  const cell = (v: unknown) => {
+    const s = String(v ?? '')
+    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
+  }
+  const wall = (iso: string) => {
+    const p = zonedParts(new Date(iso), timeZone)
+    const d = `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`
+    const t = `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`
+    return { d, t }
+  }
+
+  const rows = entries.map((e) => {
+    const start = wall(e.startedAt)
+    const end = e.endedAt ? wall(e.endedAt) : null
+    const minutes = entryMinutes(e, now)
+    return [
+      e.technicianName,
+      start.d,
+      start.t,
+      end ? (end.d === start.d ? end.t : `${end.d} ${end.t}`) : '',
+      formatMinutes(minutes),
+      minutesToDecimalHours(minutes),
+      e.job.title,
+      e.job.vehicleLabel ?? '',
+      e.job.licensePlate ?? '',
+      e.source,
+      e.editedByName ?? '',
+      e.note ?? '',
+    ]
+  })
+
+  return [headers, ...rows].map((r) => r.map(cell).join(',')).join('\n')
+}
+
+export function downloadText(filename: string, text: string, type = 'text/csv;charset=utf-8;') {
+  const blob = new Blob([text], { type })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = filename
+  a.click()
+  URL.revokeObjectURL(url)
+}

+ 242 - 0
src/features/time-tracking/Lib/timesheet.ts

@@ -0,0 +1,242 @@
+import { addZonedDays, startOfZonedDay, zonedDayKey } from '@/lib/timezone'
+
+/**
+ * Arithmetic for the timesheet, shared by the page and its tests.
+ *
+ * Nothing here touches the database or React. The server hands the page raw
+ * entries and the workshop's timezone; the page runs these against a ticking
+ * `now`, so a clock that is still running grows on screen without another
+ * round trip. Day boundaries are the workshop's, never the browser's: a
+ * manager in another country reading the sheet must see the same days the
+ * technician worked.
+ */
+
+/** One stretch as it travels to the browser: instants as ISO strings. */
+export interface SheetEntry {
+  id: string
+  startedAt: string
+  endedAt: string | null
+  durationMinutes: number | null
+  note: string | null
+  source: string
+  editedAt: string | null
+  editedByName: string | null
+  technicianId: string
+  technicianName: string
+  technicianColor: string
+  job: {
+    id: string
+    title: string
+    status: string
+    vehicleId: string | null
+    vehicleLabel: string | null
+    licensePlate: string | null
+  }
+}
+
+export interface SheetTechnician {
+  id: string
+  name: string
+  color: string
+  /** False for a board-only row nobody has linked to an account. */
+  linked?: boolean
+}
+
+/** Minutes between two instants, rounded, never negative. */
+export function minutesBetween(start: Date, end: Date): number {
+  return Math.max(0, Math.round((end.getTime() - start.getTime()) / 60_000))
+}
+
+/** When the entry ends for display purposes: its stop, or right now while it runs. */
+export function effectiveEnd(entry: Pick<SheetEntry, 'endedAt'>, now: Date): Date {
+  return entry.endedAt ? new Date(entry.endedAt) : now
+}
+
+/** Live minutes for an entry, counting a running one up to `now`. */
+export function entryMinutes(entry: SheetEntry, now: Date): number {
+  if (entry.endedAt && entry.durationMinutes !== null) return entry.durationMinutes
+  return minutesBetween(new Date(entry.startedAt), effectiveEnd(entry, now))
+}
+
+export interface DaySlice {
+  dayKey: string
+  minutes: number
+}
+
+/**
+ * Cut one entry into the workshop days it touches, clipped to a window.
+ *
+ * A stretch from 22:00 to 02:00 is two hours on one sheet and two on the
+ * next, not four on the day it started. Most entries fall inside a single
+ * day and come back as one slice.
+ */
+export function sliceByDay(
+  entry: SheetEntry,
+  timeZone: string,
+  now: Date,
+  window?: { from: Date; to: Date }
+): DaySlice[] {
+  let start = new Date(entry.startedAt)
+  let end = effectiveEnd(entry, now)
+  if (window) {
+    if (start < window.from) start = window.from
+    if (end > window.to) end = window.to
+  }
+  if (end <= start) return []
+
+  const slices: DaySlice[] = []
+  let cursor = start
+  // Bounded so a corrupt row can never spin this forever.
+  for (let guard = 0; guard < 400 && cursor < end; guard++) {
+    const nextMidnight = addZonedDays(startOfZonedDay(cursor, timeZone), 1, timeZone)
+    const sliceEnd = nextMidnight < end ? nextMidnight : end
+    const minutes = minutesBetween(cursor, sliceEnd)
+    if (minutes > 0) slices.push({ dayKey: zonedDayKey(cursor, timeZone), minutes })
+    cursor = sliceEnd
+  }
+  return slices
+}
+
+/** Every workshop day key from `from` to `to` inclusive, in order. */
+export function dayKeysBetween(from: Date, to: Date, timeZone: string): string[] {
+  const keys: string[] = []
+  let cursor = startOfZonedDay(from, timeZone)
+  const last = startOfZonedDay(to, timeZone)
+  for (let guard = 0; guard < 400 && cursor <= last; guard++) {
+    keys.push(zonedDayKey(cursor, timeZone))
+    cursor = addZonedDays(cursor, 1, timeZone)
+  }
+  return keys
+}
+
+export interface TechnicianSheet {
+  technician: SheetTechnician
+  totalMinutes: number
+  /** Minutes per workshop day, only for days with time on them. */
+  byDay: Map<string, number>
+  entries: SheetEntry[]
+  running: SheetEntry | null
+}
+
+export interface Timesheet {
+  technicians: TechnicianSheet[]
+  dayKeys: string[]
+  /** Minutes per day across every technician. */
+  dayTotals: Map<string, number>
+  totalMinutes: number
+  runningCount: number
+}
+
+/**
+ * Fold a window of entries into a per-technician, per-day sheet.
+ *
+ * Technicians with nothing in the window still appear when passed in, so a
+ * manager sees who did not clock at all, which is often the point. They sort
+ * by most time first; a technician with no time keeps the roster order.
+ */
+export function buildTimesheet(args: {
+  entries: SheetEntry[]
+  technicians: SheetTechnician[]
+  from: Date
+  to: Date
+  timeZone: string
+  now: Date
+}): Timesheet {
+  const { entries, technicians, from, to, timeZone, now } = args
+  const window = { from, to }
+  const sheets = new Map<string, TechnicianSheet>()
+
+  for (const tech of technicians) {
+    sheets.set(tech.id, {
+      technician: tech,
+      totalMinutes: 0,
+      byDay: new Map(),
+      entries: [],
+      running: null,
+    })
+  }
+
+  const dayTotals = new Map<string, number>()
+  let totalMinutes = 0
+  let runningCount = 0
+
+  for (const entry of entries) {
+    let sheet = sheets.get(entry.technicianId)
+    if (!sheet) {
+      // An entry from a technician since deactivated still counts; the sheet
+      // names them from the entry itself.
+      sheet = {
+        technician: {
+          id: entry.technicianId,
+          name: entry.technicianName,
+          color: entry.technicianColor,
+        },
+        totalMinutes: 0,
+        byDay: new Map(),
+        entries: [],
+        running: null,
+      }
+      sheets.set(entry.technicianId, sheet)
+    }
+    sheet.entries.push(entry)
+    if (!entry.endedAt) {
+      runningCount++
+      if (!sheet.running || entry.startedAt > sheet.running.startedAt) sheet.running = entry
+    }
+    for (const slice of sliceByDay(entry, timeZone, now, window)) {
+      sheet.byDay.set(slice.dayKey, (sheet.byDay.get(slice.dayKey) ?? 0) + slice.minutes)
+      dayTotals.set(slice.dayKey, (dayTotals.get(slice.dayKey) ?? 0) + slice.minutes)
+      sheet.totalMinutes += slice.minutes
+      totalMinutes += slice.minutes
+    }
+  }
+
+  const rosterIndex = new Map(technicians.map((t, i) => [t.id, i]))
+  const ordered = [...sheets.values()].sort((a, b) => {
+    if (b.totalMinutes !== a.totalMinutes) return b.totalMinutes - a.totalMinutes
+    return (
+      (rosterIndex.get(a.technician.id) ?? Number.MAX_SAFE_INTEGER) -
+      (rosterIndex.get(b.technician.id) ?? Number.MAX_SAFE_INTEGER)
+    )
+  })
+
+  return {
+    technicians: ordered,
+    dayKeys: dayKeysBetween(from, new Date(to.getTime() - 1), timeZone),
+    dayTotals,
+    totalMinutes,
+    runningCount,
+  }
+}
+
+/** "7h 05m", "45m", "0m". For totals and rows alike. */
+export function formatMinutes(minutes: number): string {
+  const m = Math.max(0, Math.round(minutes))
+  const h = Math.floor(m / 60)
+  const rest = m % 60
+  if (h === 0) return `${rest}m`
+  return `${h}h ${String(rest).padStart(2, '0')}m`
+}
+
+/** "1:05:09" while a clock runs, so the seconds visibly move. */
+export function formatElapsed(ms: number): string {
+  const total = Math.max(0, Math.floor(ms / 1000))
+  const h = Math.floor(total / 3600)
+  const m = Math.floor((total % 3600) / 60)
+  const s = total % 60
+  return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+}
+
+/**
+ * Hours for a labor line, to the nearest quarter, never below a quarter for
+ * time that was actually clocked. Nobody bills 1.37 hours.
+ */
+export function minutesToBillableHours(minutes: number): number {
+  if (minutes <= 0) return 0
+  return Math.max(0.25, Math.round(minutes / 15) / 4)
+}
+
+/** Decimal hours for CSV consumers, two places. */
+export function minutesToDecimalHours(minutes: number): string {
+  return (minutes / 60).toFixed(2)
+}

+ 21 - 0
src/features/time-tracking/hooks/useTick.ts

@@ -0,0 +1,21 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+/**
+ * A `now` that moves.
+ *
+ * Anything showing a running clock re-renders on this rather than each row
+ * running its own interval. Off entirely while nothing is running, so a
+ * sheet of finished days costs no timers at all.
+ */
+export function useTick(active: boolean, intervalMs = 1000): Date {
+  const [now, setNow] = useState(() => new Date())
+  useEffect(() => {
+    if (!active) return
+    setNow(new Date())
+    const id = setInterval(() => setNow(new Date()), intervalMs)
+    return () => clearInterval(id)
+  }, [active, intervalMs])
+  return now
+}

+ 82 - 5
src/features/vehicles/Components/MyActiveJobs.tsx

@@ -16,9 +16,11 @@ import {
   Wrench,
   Clock,
   Pause,
+  Timer,
   Plus,
   ScanBarcode,
   Package,
+  Square,
 } from 'lucide-react'
 import { FindingForm } from '@/features/vehicles/Components/FindingForm'
 import { AppCard } from '@/components/app-card'
@@ -43,12 +45,18 @@ import { InventoryPartForm } from '@/features/inventory/Components/InventoryPart
 import { CreateStatusReportDialog } from '@/features/status-reports/Components/CreateStatusReportDialog'
 import { SendStatusReportDialog } from '@/features/status-reports/Components/SendStatusReportDialog'
 import type { MyActiveJob } from '@/features/vehicles/Actions/getMyActiveJobs'
+import { useTimeClock } from '@/features/time-tracking/Components/TimeClockProvider'
+import { useTick } from '@/features/time-tracking/hooks/useTick'
+import { formatElapsed } from '@/features/time-tracking/Lib/timesheet'
+import { cn } from '@/lib/utils'
 
 interface MyActiveJobsProps {
   jobs: MyActiveJob[]
   smsEnabled: boolean
   emailEnabled: boolean
   telegramEnabled: boolean
+  /** Render a card saying so when there are no jobs, instead of nothing at all. */
+  showEmpty?: boolean
 }
 
 const STATUS_ICON: Record<string, typeof Wrench> = {
@@ -68,9 +76,16 @@ export function MyActiveJobs({
   smsEnabled,
   emailEnabled,
   telegramEnabled,
+  showEmpty = false,
 }: MyActiveJobsProps) {
   const t = useTranslations('dashboard.myJobs')
+  const tClock = useTranslations('timeTracking.job')
   const router = useRouter()
+  // The clock lives here rather than on the work order page: this list is
+  // where a technician already is, and everyone who sees it owns a
+  // technician row, so the button never has to explain itself away.
+  const clock = useTimeClock()
+  const now = useTick(clock.open !== null)
   const [uploading, setUploading] = useState<{ jobId: string; type: 'photo' | 'video' } | null>(
     null
   )
@@ -115,7 +130,59 @@ export function MyActiveJobs({
     serviceRecordId: string
   } | null>(null)
 
-  if (jobs.length === 0) return null
+  if (jobs.length === 0) {
+    if (!showEmpty) return null
+    return (
+      <AppCard icon={Wrench} title={t('title')} description={t('description')}>
+        <p className="py-6 text-center text-sm text-muted-foreground">{t('empty')}</p>
+      </AppCard>
+    )
+  }
+
+  const clockButton = (jobId: string, className?: string) => {
+    const runningHere = clock.open?.serviceRecordId === jobId
+    return runningHere ? (
+      <Button
+        variant="outline"
+        size="sm"
+        className={cn(
+          'h-9 border-primary/50 bg-primary/10 text-foreground hover:bg-primary/15',
+          className
+        )}
+        disabled={clock.busy}
+        onClick={() => void clock.stop()}
+      >
+        {clock.busy ? (
+          <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
+        ) : (
+          <span className="relative mr-1.5 flex size-2" aria-hidden>
+            <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60" />
+            <span className="relative inline-flex size-2 rounded-full bg-primary" />
+          </span>
+        )}
+        <span className="font-mono text-xs tabular-nums">
+          {formatElapsed(now.getTime() - new Date(clock.open?.startedAt ?? 0).getTime())}
+        </span>
+        <span className="ml-1.5 hidden sm:inline">{tClock('clockOut')}</span>
+        <Square className="ml-1.5 h-3 w-3 fill-current opacity-70" />
+      </Button>
+    ) : (
+      <Button
+        size="sm"
+        className={cn('h-9', className)}
+        disabled={clock.busy}
+        onClick={() => void clock.start(jobId)}
+        title={clock.open ? tClock('switchHint', { job: clock.open.jobTitle }) : undefined}
+      >
+        {clock.busy ? (
+          <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
+        ) : (
+          <Timer className="mr-1.5 h-4 w-4" />
+        )}
+        {clock.open ? tClock('switchHere') : tClock('clockIn')}
+      </Button>
+    )
+  }
 
   const handleCameraClick = (jobId: string) => {
     fileInputRefs.current[jobId]?.click()
@@ -342,7 +409,15 @@ export function MyActiveJobs({
                       <StatusIcon className="h-4 w-4" />
                     </div>
                     <div className="min-w-0">
-                      <p className="font-medium text-sm truncate">{job.title}</p>
+                      <p className="flex items-center gap-2 font-medium text-sm truncate">
+                        <span className="truncate">{job.title}</span>
+                        {clock.open?.serviceRecordId === job.id && (
+                          <span className="relative flex size-2 shrink-0" aria-hidden>
+                            <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60" />
+                            <span className="relative inline-flex size-2 rounded-full bg-primary" />
+                          </span>
+                        )}
+                      </p>
                       <p className="text-xs text-muted-foreground truncate">
                         {job.vehicle
                           ? `${job.vehicle.year} ${job.vehicle.make} ${job.vehicle.model}${job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ''}`
@@ -351,7 +426,7 @@ export function MyActiveJobs({
                     </div>
                   </div>
                   {/* Desktop: inline buttons */}
-                  <div className="shrink-0 ml-3 hidden sm:flex items-center gap-2">
+                  <div className="shrink-0 ml-3 hidden lg:flex items-center gap-2">
                     <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
                       {imgCount > 0 && (
                         <Tooltip>
@@ -387,6 +462,7 @@ export function MyActiveJobs({
                         </Tooltip>
                       )}
                     </div>
+                    {clockButton(job.id)}
                     <Button
                       variant="outline"
                       size="sm"
@@ -451,7 +527,7 @@ export function MyActiveJobs({
                     )}
                   </div>
                   {/* Mobile: counters only */}
-                  <div className="shrink-0 ml-3 flex sm:hidden items-center gap-1.5 text-xs text-muted-foreground">
+                  <div className="shrink-0 ml-3 flex lg:hidden items-center gap-1.5 text-xs text-muted-foreground">
                     {imgCount > 0 && (
                       <Tooltip>
                         <TooltipTrigger asChild>
@@ -488,7 +564,8 @@ export function MyActiveJobs({
                   </div>
                 </div>
                 {/* Mobile: action buttons on two rows */}
-                <div className="mt-2 flex flex-col gap-1.5 sm:hidden">
+                <div className="mt-2 flex flex-col gap-1.5 lg:hidden">
+                  {clockButton(job.id, 'w-full')}
                   <ButtonGroup className="w-full">
                     <Button
                       variant="outline"

Некоторые файлы не были показаны из-за большого количества измененных файлов