From fe3eba6e44a531c2f3bb4dcd9192ada8be35aa67 Mon Sep 17 00:00:00 2001
From: Martin
Date: Tue, 15 Sep 2026 09:29:38 +0200
Subject: [PATCH] L12 Zeiterfassung: Mobile Uhr-Leiste, Meine Zeiten, Freigaben
und Backoffice-Liste
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Laufende-Uhr-Leiste auf allen /m-Seiten, Start/Pause direkt auf den Auftragskarten,
Auto-Wechsel-Dialog, Auftragsdetail mit Pause / Für heute beenden / Abschließen-Link
und Segmentwechseln, /m/time (Tagesübersicht, Nachtragen, Korrektur vorschlagen),
/m/approvals für Teamleiter, /work-orders/time-approvals mit Sammelfreigabe,
Zeiten-Tab mit Badges und Inline-Freigabe, Dashboard-Kachel, Texte de/en.
Co-Authored-By: Claude Opus 5
---
messages/de/dashboard.json | 6 +-
messages/de/field.json | 154 ++++++++++++-
messages/de/nav.json | 3 +-
messages/de/notifications.json | 102 +++++++--
messages/de/offline.json | 5 +-
messages/de/workOrders.json | 47 +++-
messages/en/dashboard.json | 6 +-
messages/en/field.json | 154 ++++++++++++-
messages/en/nav.json | 3 +-
messages/en/notifications.json | 102 +++++++--
messages/en/offline.json | 5 +-
messages/en/workOrders.json | 47 +++-
src/app/(app)/dashboard/page.tsx | 10 +-
.../(app)/work-orders/time-approvals/page.tsx | 116 ++++++++++
src/app/(field)/m/(core)/approvals/page.tsx | 75 +++++++
src/app/(field)/m/(core)/orders/[id]/page.tsx | 7 +-
.../m/(core)/orders/[id]/report/page.tsx | 10 +-
.../m/(core)/orders/[id]/time/page.tsx | 62 ++++--
src/app/(field)/m/(core)/orders/page.tsx | 4 +-
src/app/(field)/m/(core)/page.tsx | 4 +-
src/app/(field)/m/(core)/profile/page.tsx | 14 +-
src/app/(field)/m/(core)/time/new/page.tsx | 35 +++
src/app/(field)/m/(core)/time/page.tsx | 105 +++++++++
src/app/(field)/m/layout.tsx | 31 ++-
src/components/field/approval-actions.tsx | 73 ++++++
src/components/field/bottom-nav.tsx | 16 +-
src/components/field/card-time-button.tsx | 56 +++++
.../field/correction-proposal-form.tsx | 150 +++++++++++++
src/components/field/local-pending-times.tsx | 64 ++++++
src/components/field/manual-time-form.tsx | 207 ++++++++++++++++++
src/components/field/order-card.tsx | 33 ++-
src/components/field/pending-time-notice.tsx | 40 ++++
src/components/field/primary-action.tsx | 163 +++++++++-----
src/components/field/running-clock-bar.tsx | 165 ++++++++++++++
src/components/field/switch-session-sheet.tsx | 106 +++++++++
src/components/field/time-entry-badges.tsx | 72 ++++++
src/components/offline/offline-view.tsx | 10 +-
src/components/work-orders/detail-tabs.tsx | 59 ++++-
.../work-orders/time-approval-forms.tsx | 53 +++++
src/lib/nav.ts | 2 +
src/server/actions/field/time.ts | 38 +++-
.../actions/work_orders/time-approvals.ts | 71 ++++++
42 files changed, 2319 insertions(+), 166 deletions(-)
create mode 100644 src/app/(app)/work-orders/time-approvals/page.tsx
create mode 100644 src/app/(field)/m/(core)/approvals/page.tsx
create mode 100644 src/app/(field)/m/(core)/time/new/page.tsx
create mode 100644 src/app/(field)/m/(core)/time/page.tsx
create mode 100644 src/components/field/approval-actions.tsx
create mode 100644 src/components/field/card-time-button.tsx
create mode 100644 src/components/field/correction-proposal-form.tsx
create mode 100644 src/components/field/local-pending-times.tsx
create mode 100644 src/components/field/manual-time-form.tsx
create mode 100644 src/components/field/pending-time-notice.tsx
create mode 100644 src/components/field/running-clock-bar.tsx
create mode 100644 src/components/field/switch-session-sheet.tsx
create mode 100644 src/components/field/time-entry-badges.tsx
create mode 100644 src/components/work-orders/time-approval-forms.tsx
create mode 100644 src/server/actions/work_orders/time-approvals.ts
diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json
index 33e7ba1..7388acd 100644
--- a/messages/de/dashboard.json
+++ b/messages/de/dashboard.json
@@ -16,7 +16,8 @@
"billing": "Zur Abrechnung",
"emergency_new": "Neue Notdienste",
"missing_signatures": "Fehlende Unterschriften",
- "sync_conflicts": "Sync-Konflikte"
+ "sync_conflicts": "Sync-Konflikte",
+ "time_approvals": "Zeiten zur Freigabe"
},
"hints": {
"open": "Noch nicht abgerechnet oder storniert",
@@ -29,7 +30,8 @@
"billing": "Bereit zur Abrechnung",
"emergency_new": "Neu oder zur Prüfung",
"missing_signatures": "Unterschrift ausstehend",
- "sync_conflicts": "Offline-Änderungen prüfen"
+ "sync_conflicts": "Offline-Änderungen prüfen",
+ "time_approvals": "Nachträge und Korrekturen prüfen"
},
"open": "Anzeigen"
}
diff --git a/messages/de/field.json b/messages/de/field.json
index ba53ccf..c1b3f21 100644
--- a/messages/de/field.json
+++ b/messages/de/field.json
@@ -5,7 +5,8 @@
"orders": "Aufträge",
"emergency": "Notdienst",
"sync": "Sync",
- "profile": "Profil"
+ "profile": "Profil",
+ "approvalsBadge": "{count} Zeiten zur Freigabe"
},
"connection": {
"online": "Online",
@@ -35,7 +36,13 @@
"noDate": "Ohne Termin",
"emergency": "Notdienst",
"urgent": "Dringend",
- "high": "Hoch"
+ "high": "Hoch",
+ "running": "Zeit läuft",
+ "paused": "Pausiert",
+ "enRoute": "In Anfahrt",
+ "startWork": "Arbeit starten",
+ "pause": "Pause",
+ "resume": "Weiter"
},
"statusGroup": {
"new": "Neu",
@@ -78,7 +85,17 @@
"saving": "Wird gespeichert …",
"blocked": "Vor dem Abschluss noch offen:",
"reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.",
- "nothing": "Keine Aktion erforderlich."
+ "nothing": "Keine Aktion erforderlich.",
+ "stop_day": "Für heute beenden",
+ "confirmStopDay": "Jetzt für heute beenden",
+ "stopDayHint": "Die Zeiterfassung endet, der Auftrag bleibt offen und kann morgen fortgesetzt werden.",
+ "completeLink": "Auftrag abschließen …",
+ "segment": {
+ "return_travel": "Rückfahrt starten",
+ "material_procurement": "Material holen",
+ "work": "Zurück zur Arbeit"
+ },
+ "currentSegment": "Aktuell: {type}"
},
"blocker": {
"checklist_item": "Checkliste: {label}",
@@ -264,7 +281,8 @@
"en_route": "In Anfahrt",
"running": "Läuft",
"paused": "Pausiert",
- "ended": "Beendet"
+ "ended": "Beendet",
+ "manual": "Nachgetragen"
},
"running": "läuft",
"offlineStarted": "offline gestartet",
@@ -277,7 +295,10 @@
"save": "Korrektur speichern",
"saved": "Korrektur gespeichert.",
"empty": "Noch keine Zeiten erfasst.",
- "totalWork": "Arbeitszeit gesamt: {duration}"
+ "totalWork": "Arbeitszeit gesamt: {duration}",
+ "propose": "Korrektur vorschlagen",
+ "totalPending": "Zur Freigabe offen: {duration}",
+ "ownEntryHint": "Eigene Zeiten korrigieren Sie über „Korrektur vorschlagen“."
},
"profile": {
"title": "Profil",
@@ -287,12 +308,133 @@
"roles": "Rolle",
"language": "Sprache",
"logout": "Abmelden",
- "backoffice": "Zum Büro-Bereich"
+ "backoffice": "Zum Büro-Bereich",
+ "myTime": "Meine Zeiten",
+ "approvals": "Zeiten freigeben"
},
"sync": {
"title": "Synchronisation",
"immediate": "Änderungen werden sofort an das Büro übertragen.",
"offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.",
"status": "Verbindung"
+ },
+ "clock": {
+ "label": "Laufende Zeiterfassung",
+ "running": "Läuft",
+ "paused": "Pausiert",
+ "enRoute": "Anfahrt",
+ "openOrder": "Auftrag {number} öffnen",
+ "pause": "Pause",
+ "resume": "Weiter",
+ "stopDay": "Für heute beenden",
+ "stopDayConfirm": "Beenden?",
+ "myTime": "Meine Zeiten",
+ "offline": "offline"
+ },
+ "switch": {
+ "title": "Andere Zeiterfassung läuft",
+ "text": "{from} läuft noch. Pausieren und {to} starten?",
+ "textUnknown": "Auf einem anderen Auftrag läuft noch eine Zeiterfassung. Pausieren und {to} starten?",
+ "confirm": "Wechseln",
+ "cancel": "Abbrechen"
+ },
+ "myTime": {
+ "title": "Meine Zeiten",
+ "sub": "Ihre Zeiten je Tag über alle Aufträge.",
+ "today": "Heute",
+ "yesterday": "Gestern",
+ "days": "Tag wählen",
+ "empty": "An diesem Tag sind keine Zeiten erfasst.",
+ "approved": "Freigegeben",
+ "pending": "Zur Freigabe",
+ "dayTotal": "Tagessumme",
+ "totalApproved": "Freigegeben: {duration}",
+ "totalPending": "Offen: {duration}",
+ "add": "Zeit nachtragen",
+ "running": "läuft",
+ "badge": {
+ "manual": "Manuell",
+ "pending": "Zur Freigabe",
+ "rejected": "Abgelehnt: {reason}",
+ "correctionRequested": "Korrektur beantragt",
+ "correctionRejected": "Korrektur abgelehnt: {reason}",
+ "corrected": "Korrigiert"
+ },
+ "proposed": "Vorschlag: {from}–{to} · {type}",
+ "localPending": "Wartet auf Übertragung",
+ "localTitle": "Noch nicht übertragen",
+ "form": {
+ "title": "Zeit nachtragen",
+ "order": "Auftrag",
+ "orderPlaceholder": "Auftrag wählen",
+ "noOrders": "Keine Aufträge der letzten 7 Tage verfügbar.",
+ "forUser": "Für",
+ "me": "Mich selbst",
+ "type": "Art",
+ "date": "Datum",
+ "mode": "Erfassung",
+ "modeRange": "Von – Bis",
+ "modeDuration": "Dauer",
+ "from": "Von",
+ "to": "Bis",
+ "duration": "Dauer in Minuten",
+ "quick": "{minutes} min",
+ "reason": "Begründung",
+ "reasonHint": "Pflichtangabe, mindestens 3 Zeichen.",
+ "note": "Notiz (optional)",
+ "approvalHint": "Wird nach Freigabe durch Teamleiter oder Büro gezählt.",
+ "directHint": "Für Teammitglieder erfasste Zeiten gelten sofort als freigegeben.",
+ "save": "Nachtrag speichern",
+ "saved": "Nachtrag gespeichert – wartet auf Freigabe.",
+ "savedDirect": "Zeit gespeichert.",
+ "queued": "Offline gespeichert – wird übertragen, sobald Verbindung besteht.",
+ "cancel": "Abbrechen"
+ },
+ "correction": {
+ "title": "Korrektur vorschlagen",
+ "hint": "Bis zur Freigabe gilt die bisherige Zeit.",
+ "save": "Vorschlag senden",
+ "saved": "Korrektur beantragt."
+ },
+ "reasons": {
+ "forgotStart": "Start vergessen",
+ "noNetwork": "Kein Netz",
+ "recordedLater": "Nachträglich erfasst"
+ },
+ "errors": {
+ "overlap": "Der Zeitraum überschneidet sich mit einer bereits erfassten Zeit.",
+ "too_old": "Nachtragen ist nur für die letzten 7 Tage möglich.",
+ "in_future": "Die Zeit darf nicht in der Zukunft liegen.",
+ "too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.",
+ "end_before_start": "Das Ende muss nach dem Beginn liegen.",
+ "work_order_status": "Für diesen Auftrag können keine Zeiten mehr erfasst werden.",
+ "not_own_entry": "Sie können nur eigene Zeiten korrigieren.",
+ "own_entry": "Eigene Zeiten können Sie nicht selbst freigeben.",
+ "entry_running": "Die Zeit läuft noch."
+ }
+ },
+ "approvals": {
+ "title": "Zeiten freigeben",
+ "sub": "Nachträge und Korrekturen Ihres Teams.",
+ "empty": "Keine offenen Nachträge oder Korrekturen.",
+ "link": "Zeiten freigeben",
+ "count": "{count, plural, =0 {Nichts offen} one {# offen} other {# offen}}",
+ "manual": "Nachtrag",
+ "correction": "Korrektur",
+ "reason": "Begründung",
+ "note": "Notiz",
+ "old": "Bisher",
+ "new": "Neu",
+ "approve": "Freigeben",
+ "reject": "Ablehnen",
+ "rejectReason": "Grund der Ablehnung",
+ "rejectConfirm": "Ablehnung senden",
+ "cancel": "Abbrechen",
+ "approved": "Freigegeben.",
+ "rejected": "Abgelehnt."
+ },
+ "pendingTime": {
+ "report": "Hinweis: {duration} nachgetragene Zeit ist noch nicht freigegeben und fehlt im Bericht.",
+ "proposals": "Hinweis: {count, plural, one {# Zeitkorrektur wartet} other {# Zeitkorrekturen warten}} auf Freigabe."
}
}
diff --git a/messages/de/nav.json b/messages/de/nav.json
index efed3cc..f49d785 100644
--- a/messages/de/nav.json
+++ b/messages/de/nav.json
@@ -16,5 +16,6 @@
"lotse": "Lotse (KI)",
"admin": "Admin-Konsole",
"openMenu": "Menü öffnen",
- "closeMenu": "Menü schließen"
+ "closeMenu": "Menü schließen",
+ "timeApprovals": "Zeiten zur Freigabe"
}
diff --git a/messages/de/notifications.json b/messages/de/notifications.json
index 3689c31..5685b8c 100644
--- a/messages/de/notifications.json
+++ b/messages/de/notifications.json
@@ -45,26 +45,92 @@
"emergency_completed": "Notdiensteinsatz abgeschlossen",
"import_ready_for_review": "Import zur Prüfung",
"import_failed": "Fehler bei Dokumentverarbeitung",
- "sync_failed": "Synchronisationsfehler"
+ "sync_failed": "Synchronisationsfehler",
+ "time_approval_requested": "Zeit zur Freigabe",
+ "time_approved": "Zeit freigegeben",
+ "time_rejected": "Zeit abgelehnt"
},
"events": {
- "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." },
- "work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." },
- "work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." },
- "work_order_started": { "title": "Auftrag {number} gestartet", "message": "{actor} hat den Einsatz bei {customer} begonnen." },
- "work_order_daily_report_created": { "title": "Tagesbericht zu {number}", "message": "{actor} hat einen Tagesbericht für {customer} erstellt." },
- "work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." },
- "work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." },
- "work_order_missing_required": { "title": "Pflichtangaben fehlen: {number}", "message": "{title} – {customer}: Dokumentation unvollständig." },
- "work_order_released_for_billing": { "title": "Auftrag {number} zur Abrechnung", "message": "{title} – {customer} ist bereit zur Abrechnung." },
- "report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." },
- "report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." },
- "report_rejected": { "title": "Bericht zurückgewiesen: {number}", "message": "Der Bericht zu {title} braucht eine Korrektur." },
- "emergency_created": { "title": "Notdiensteinsatz {number} erstellt", "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." },
- "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." },
- "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." },
- "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." },
- "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." }
+ "work_order_assigned": {
+ "title": "Neuer Auftrag {number}",
+ "message": "{title} – {customer} wurde Ihrem Team zugewiesen."
+ },
+ "work_order_changed": {
+ "title": "Auftrag {number} geändert",
+ "message": "{title} – {customer}: Angaben wurden aktualisiert."
+ },
+ "work_order_cancelled": {
+ "title": "Auftrag {number} storniert",
+ "message": "{title} – {customer} wurde storniert."
+ },
+ "work_order_started": {
+ "title": "Auftrag {number} gestartet",
+ "message": "{actor} hat den Einsatz bei {customer} begonnen."
+ },
+ "work_order_daily_report_created": {
+ "title": "Tagesbericht zu {number}",
+ "message": "{actor} hat einen Tagesbericht für {customer} erstellt."
+ },
+ "work_order_technically_completed": {
+ "title": "Auftrag {number} technisch abgeschlossen",
+ "message": "{title} – {customer}: Einsatz vor Ort beendet."
+ },
+ "work_order_signature_missing": {
+ "title": "Unterschrift fehlt: {number}",
+ "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor."
+ },
+ "work_order_missing_required": {
+ "title": "Pflichtangaben fehlen: {number}",
+ "message": "{title} – {customer}: Dokumentation unvollständig."
+ },
+ "work_order_released_for_billing": {
+ "title": "Auftrag {number} zur Abrechnung",
+ "message": "{title} – {customer} ist bereit zur Abrechnung."
+ },
+ "report_submitted": {
+ "title": "Bericht zur Prüfung: {number}",
+ "message": "{actor} hat einen Bericht zu {title} eingereicht."
+ },
+ "report_approved": {
+ "title": "Bericht freigegeben: {number}",
+ "message": "Der Bericht zu {title} wurde freigegeben."
+ },
+ "report_rejected": {
+ "title": "Bericht zurückgewiesen: {number}",
+ "message": "Der Bericht zu {title} braucht eine Korrektur."
+ },
+ "emergency_created": {
+ "title": "Notdiensteinsatz {number} erstellt",
+ "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt."
+ },
+ "emergency_completed": {
+ "title": "Notdiensteinsatz {number} abgeschlossen",
+ "message": "{customer}: zur Prüfung und Abrechnung."
+ },
+ "import_ready_for_review": {
+ "title": "Import zur Prüfung bereit",
+ "message": "Die Daten aus {fileName} liegen zur Prüfung vor."
+ },
+ "import_failed": {
+ "title": "Fehler bei Dokumentverarbeitung",
+ "message": "{fileName} konnte nicht verarbeitet werden."
+ },
+ "sync_failed": {
+ "title": "Synchronisation fehlgeschlagen",
+ "message": "Eine Änderung konnte nicht übernommen werden ({reason})."
+ },
+ "time_approval_requested": {
+ "title": "Zeit zur Freigabe: {number}",
+ "message": "{actor} hat eine Zeit zu {number} nachgetragen oder eine Korrektur beantragt."
+ },
+ "time_approved": {
+ "title": "Zeit freigegeben: {number}",
+ "message": "{actor} hat Ihre Zeit zu {number} freigegeben."
+ },
+ "time_rejected": {
+ "title": "Zeit abgelehnt: {number}",
+ "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}"
+ }
},
"fallback": {
"unknown": "unbekannt",
diff --git a/messages/de/offline.json b/messages/de/offline.json
index 07a4bbb..89336f8 100644
--- a/messages/de/offline.json
+++ b/messages/de/offline.json
@@ -89,7 +89,10 @@
"upload": "Die Datei wurde nicht angenommen (Format oder Größe).",
"unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.",
"network": "Keine Verbindung – wird automatisch erneut versucht.",
- "internal": "Technischer Fehler – wird automatisch erneut versucht."
+ "internal": "Technischer Fehler – wird automatisch erneut versucht.",
+ "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
+ "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
+ "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln."
},
"view": {
"title": "Offline-Ansicht",
diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json
index 0cb6433..94eb659 100644
--- a/messages/de/workOrders.json
+++ b/messages/de/workOrders.json
@@ -237,7 +237,21 @@
"material_procurement": "Materialbeschaffung",
"return_travel": "Rückfahrt",
"interruption": "Unterbrechung"
- }
+ },
+ "pendingMinutes": "Zur Freigabe offen: {minutes} min",
+ "approvedMinutes": "Freigegeben",
+ "badgeManual": "Manuell",
+ "badgePending": "Zur Freigabe",
+ "badgeRejected": "Abgelehnt: {reason}",
+ "badgeCorrection": "Korrektur beantragt: {from}–{to}",
+ "reason": "Begründung: {reason}",
+ "manualSession": "Nachgetragen",
+ "approve": "Freigeben",
+ "reject": "Ablehnen",
+ "rejectReason": "Grund",
+ "approved": "Freigegeben.",
+ "rejected": "Abgelehnt.",
+ "openApprovals": "Alle offenen Freigaben"
},
"photos": {
"empty": "Noch keine Fotos.",
@@ -460,8 +474,35 @@
"running_session": "Ein Einsatz läuft noch",
"field": {
"signature": "Unterschrift fehlt",
- "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben"
+ "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben",
+ "pending_time_entries": "Zeiten warten noch auf Freigabe"
}
},
- "saved": "Gespeichert."
+ "saved": "Gespeichert.",
+ "timeApprovals": {
+ "crumb": "Aufträge",
+ "title": "Zeiten zur Freigabe",
+ "sub": "Nachträge und Korrekturen der Monteure. Erst nach Freigabe zählen sie für Bericht und Abrechnung.",
+ "empty": "Keine offenen Nachträge oder Korrekturen.",
+ "employee": "Mitarbeiter",
+ "order": "Auftrag",
+ "date": "Datum",
+ "range": "Von–Bis",
+ "duration": "Dauer",
+ "type": "Art",
+ "reason": "Begründung",
+ "kindManual": "Nachtrag",
+ "kindCorrection": "Korrektur",
+ "oldNew": "{old} → {new}",
+ "select": "Auswählen",
+ "selectAll": "Alle auswählen",
+ "approveSelected": "Ausgewählte freigeben",
+ "approve": "Freigeben",
+ "reject": "Ablehnen",
+ "rejectReason": "Grund der Ablehnung",
+ "approved": "Freigegeben.",
+ "bulkApproved": "{ok} freigegeben, {failed} nicht möglich.",
+ "rejected": "Abgelehnt.",
+ "count": "{count, plural, one {# offener Eintrag} other {# offene Einträge}}"
+ }
}
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index eee1199..3f36189 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -16,7 +16,8 @@
"billing": "Ready for billing",
"emergency_new": "New emergencies",
"missing_signatures": "Missing signatures",
- "sync_conflicts": "Sync conflicts"
+ "sync_conflicts": "Sync conflicts",
+ "time_approvals": "Time approvals"
},
"hints": {
"open": "Not yet billed or cancelled",
@@ -29,7 +30,8 @@
"billing": "Ready to invoice",
"emergency_new": "New or in review",
"missing_signatures": "Signature pending",
- "sync_conflicts": "Review offline changes"
+ "sync_conflicts": "Review offline changes",
+ "time_approvals": "Review added time and corrections"
},
"open": "Show"
}
diff --git a/messages/en/field.json b/messages/en/field.json
index d8a5456..57a6825 100644
--- a/messages/en/field.json
+++ b/messages/en/field.json
@@ -5,7 +5,8 @@
"orders": "Orders",
"emergency": "Emergency",
"sync": "Sync",
- "profile": "Profile"
+ "profile": "Profile",
+ "approvalsBadge": "{count} time entries to approve"
},
"connection": {
"online": "Online",
@@ -35,7 +36,13 @@
"noDate": "No date",
"emergency": "Emergency",
"urgent": "Urgent",
- "high": "High"
+ "high": "High",
+ "running": "Time running",
+ "paused": "Paused",
+ "enRoute": "On the way",
+ "startWork": "Start work",
+ "pause": "Pause",
+ "resume": "Resume"
},
"statusGroup": {
"new": "New",
@@ -78,7 +85,17 @@
"saving": "Saving …",
"blocked": "Still missing before completion:",
"reportNext": "Report and signature follow in the next step.",
- "nothing": "No action required."
+ "nothing": "No action required.",
+ "stop_day": "Stop for today",
+ "confirmStopDay": "Stop for today now",
+ "stopDayHint": "Time recording ends, the order stays open and can be continued tomorrow.",
+ "completeLink": "Complete order …",
+ "segment": {
+ "return_travel": "Start return trip",
+ "material_procurement": "Fetch material",
+ "work": "Back to work"
+ },
+ "currentSegment": "Current: {type}"
},
"blocker": {
"checklist_item": "Checklist: {label}",
@@ -264,7 +281,8 @@
"en_route": "On the way",
"running": "Running",
"paused": "Paused",
- "ended": "Ended"
+ "ended": "Ended",
+ "manual": "Recorded later"
},
"running": "running",
"offlineStarted": "started offline",
@@ -277,7 +295,10 @@
"save": "Save correction",
"saved": "Correction saved.",
"empty": "No time recorded yet.",
- "totalWork": "Total working time: {duration}"
+ "totalWork": "Total working time: {duration}",
+ "propose": "Propose correction",
+ "totalPending": "Awaiting approval: {duration}",
+ "ownEntryHint": "Correct your own time via “Propose correction”."
},
"profile": {
"title": "Profile",
@@ -287,12 +308,133 @@
"roles": "Role",
"language": "Language",
"logout": "Sign out",
- "backoffice": "Go to office area"
+ "backoffice": "Go to office area",
+ "myTime": "My time",
+ "approvals": "Approve time"
},
"sync": {
"title": "Synchronisation",
"immediate": "Changes are sent to the office immediately.",
"offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.",
"status": "Connection"
+ },
+ "clock": {
+ "label": "Running time recording",
+ "running": "Running",
+ "paused": "Paused",
+ "enRoute": "Travel",
+ "openOrder": "Open order {number}",
+ "pause": "Pause",
+ "resume": "Resume",
+ "stopDay": "Stop for today",
+ "stopDayConfirm": "Stop?",
+ "myTime": "My time",
+ "offline": "offline"
+ },
+ "switch": {
+ "title": "Another time recording is running",
+ "text": "{from} is still running. Pause it and start {to}?",
+ "textUnknown": "Time is still running on another order. Pause it and start {to}?",
+ "confirm": "Switch",
+ "cancel": "Cancel"
+ },
+ "myTime": {
+ "title": "My time",
+ "sub": "Your time per day across all orders.",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "days": "Choose day",
+ "empty": "No time recorded on this day.",
+ "approved": "Approved",
+ "pending": "Awaiting approval",
+ "dayTotal": "Day total",
+ "totalApproved": "Approved: {duration}",
+ "totalPending": "Open: {duration}",
+ "add": "Add time",
+ "running": "running",
+ "badge": {
+ "manual": "Manual",
+ "pending": "Awaiting approval",
+ "rejected": "Rejected: {reason}",
+ "correctionRequested": "Correction requested",
+ "correctionRejected": "Correction rejected: {reason}",
+ "corrected": "Corrected"
+ },
+ "proposed": "Proposal: {from}–{to} · {type}",
+ "localPending": "Waiting to be sent",
+ "localTitle": "Not sent yet",
+ "form": {
+ "title": "Add time",
+ "order": "Order",
+ "orderPlaceholder": "Choose order",
+ "noOrders": "No orders from the last 7 days available.",
+ "forUser": "For",
+ "me": "Myself",
+ "type": "Type",
+ "date": "Date",
+ "mode": "Entry",
+ "modeRange": "From – to",
+ "modeDuration": "Duration",
+ "from": "From",
+ "to": "To",
+ "duration": "Duration in minutes",
+ "quick": "{minutes} min",
+ "reason": "Reason",
+ "reasonHint": "Required, at least 3 characters.",
+ "note": "Note (optional)",
+ "approvalHint": "Counts once approved by the team lead or office.",
+ "directHint": "Time recorded for team members is approved immediately.",
+ "save": "Save entry",
+ "saved": "Entry saved – awaiting approval.",
+ "savedDirect": "Time saved.",
+ "queued": "Saved offline – will be sent once connected.",
+ "cancel": "Cancel"
+ },
+ "correction": {
+ "title": "Propose correction",
+ "hint": "The current time stays valid until approved.",
+ "save": "Send proposal",
+ "saved": "Correction requested."
+ },
+ "reasons": {
+ "forgotStart": "Forgot to start",
+ "noNetwork": "No network",
+ "recordedLater": "Recorded afterwards"
+ },
+ "errors": {
+ "overlap": "The period overlaps with time already recorded.",
+ "too_old": "Time can only be added for the last 7 days.",
+ "in_future": "The time must not be in the future.",
+ "too_long": "An entry may be at most 16 hours long.",
+ "end_before_start": "The end must be after the start.",
+ "work_order_status": "Time can no longer be recorded for this order.",
+ "not_own_entry": "You can only correct your own time.",
+ "own_entry": "You cannot approve your own time.",
+ "entry_running": "The time is still running."
+ }
+ },
+ "approvals": {
+ "title": "Approve time",
+ "sub": "Added entries and corrections of your team.",
+ "empty": "No open entries or corrections.",
+ "link": "Approve time",
+ "count": "{count, plural, =0 {Nothing open} one {# open} other {# open}}",
+ "manual": "Added entry",
+ "correction": "Correction",
+ "reason": "Reason",
+ "note": "Note",
+ "old": "Before",
+ "new": "New",
+ "approve": "Approve",
+ "reject": "Reject",
+ "rejectReason": "Reason for rejection",
+ "rejectConfirm": "Send rejection",
+ "cancel": "Cancel",
+ "approved": "Approved.",
+ "rejected": "Rejected."
+ },
+ "pendingTime": {
+ "report": "Note: {duration} of added time is not approved yet and missing from the report.",
+ "proposals": "Note: {count, plural, one {# time correction is} other {# time corrections are}} awaiting approval."
}
}
diff --git a/messages/en/nav.json b/messages/en/nav.json
index 91f4ba6..5e97423 100644
--- a/messages/en/nav.json
+++ b/messages/en/nav.json
@@ -16,5 +16,6 @@
"lotse": "Lotse (AI)",
"admin": "Admin console",
"openMenu": "Open menu",
- "closeMenu": "Close menu"
+ "closeMenu": "Close menu",
+ "timeApprovals": "Time approvals"
}
diff --git a/messages/en/notifications.json b/messages/en/notifications.json
index 3206fdf..13ca202 100644
--- a/messages/en/notifications.json
+++ b/messages/en/notifications.json
@@ -45,26 +45,92 @@
"emergency_completed": "Emergency call-out completed",
"import_ready_for_review": "Import ready for review",
"import_failed": "Document processing failed",
- "sync_failed": "Sync failed"
+ "sync_failed": "Sync failed",
+ "time_approval_requested": "Time to approve",
+ "time_approved": "Time approved",
+ "time_rejected": "Time rejected"
},
"events": {
- "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." },
- "work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." },
- "work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." },
- "work_order_started": { "title": "Work order {number} started", "message": "{actor} started work at {customer}." },
- "work_order_daily_report_created": { "title": "Daily report for {number}", "message": "{actor} created a daily report for {customer}." },
- "work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." },
- "work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." },
- "work_order_missing_required": { "title": "Required information missing: {number}", "message": "{title} – {customer}: documentation incomplete." },
- "work_order_released_for_billing": { "title": "Work order {number} ready for billing", "message": "{title} – {customer} is ready for billing." },
- "report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." },
- "report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." },
- "report_rejected": { "title": "Report rejected: {number}", "message": "The report for {title} needs a correction." },
- "emergency_created": { "title": "Emergency call-out {number} created", "message": "{actor} created an emergency call-out at {customer}." },
- "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." },
- "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." },
- "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." },
- "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." }
+ "work_order_assigned": {
+ "title": "New work order {number}",
+ "message": "{title} – {customer} was assigned to your team."
+ },
+ "work_order_changed": {
+ "title": "Work order {number} changed",
+ "message": "{title} – {customer}: details were updated."
+ },
+ "work_order_cancelled": {
+ "title": "Work order {number} cancelled",
+ "message": "{title} – {customer} was cancelled."
+ },
+ "work_order_started": {
+ "title": "Work order {number} started",
+ "message": "{actor} started work at {customer}."
+ },
+ "work_order_daily_report_created": {
+ "title": "Daily report for {number}",
+ "message": "{actor} created a daily report for {customer}."
+ },
+ "work_order_technically_completed": {
+ "title": "Work order {number} technically completed",
+ "message": "{title} – {customer}: on-site work finished."
+ },
+ "work_order_signature_missing": {
+ "title": "Signature missing: {number}",
+ "message": "{title} – {customer}: no customer signature."
+ },
+ "work_order_missing_required": {
+ "title": "Required information missing: {number}",
+ "message": "{title} – {customer}: documentation incomplete."
+ },
+ "work_order_released_for_billing": {
+ "title": "Work order {number} ready for billing",
+ "message": "{title} – {customer} is ready for billing."
+ },
+ "report_submitted": {
+ "title": "Report for review: {number}",
+ "message": "{actor} submitted a report for {title}."
+ },
+ "report_approved": {
+ "title": "Report approved: {number}",
+ "message": "The report for {title} was approved."
+ },
+ "report_rejected": {
+ "title": "Report rejected: {number}",
+ "message": "The report for {title} needs a correction."
+ },
+ "emergency_created": {
+ "title": "Emergency call-out {number} created",
+ "message": "{actor} created an emergency call-out at {customer}."
+ },
+ "emergency_completed": {
+ "title": "Emergency call-out {number} completed",
+ "message": "{customer}: ready for review and billing."
+ },
+ "import_ready_for_review": {
+ "title": "Import ready for review",
+ "message": "The data from {fileName} is ready for review."
+ },
+ "import_failed": {
+ "title": "Document processing failed",
+ "message": "{fileName} could not be processed."
+ },
+ "sync_failed": {
+ "title": "Sync failed",
+ "message": "A change could not be applied ({reason})."
+ },
+ "time_approval_requested": {
+ "title": "Time to approve: {number}",
+ "message": "{actor} added time or requested a correction for {number}."
+ },
+ "time_approved": {
+ "title": "Time approved: {number}",
+ "message": "{actor} approved your time for {number}."
+ },
+ "time_rejected": {
+ "title": "Time rejected: {number}",
+ "message": "{actor} rejected your time for {number}: {reason}"
+ }
},
"fallback": {
"unknown": "unknown",
diff --git a/messages/en/offline.json b/messages/en/offline.json
index 2a982be..b2ec4a9 100644
--- a/messages/en/offline.json
+++ b/messages/en/offline.json
@@ -89,7 +89,10 @@
"upload": "The file was not accepted (format or size).",
"unauthorized": "Your session expired – please sign in again.",
"network": "No connection – will retry automatically.",
- "internal": "Technical error – will retry automatically."
+ "internal": "Technical error – will retry automatically.",
+ "timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
+ "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
+ "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch."
},
"view": {
"title": "Offline view",
diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json
index 166e853..9ff39e2 100644
--- a/messages/en/workOrders.json
+++ b/messages/en/workOrders.json
@@ -237,7 +237,21 @@
"material_procurement": "Material procurement",
"return_travel": "Return travel",
"interruption": "Interruption"
- }
+ },
+ "pendingMinutes": "Awaiting approval: {minutes} min",
+ "approvedMinutes": "Approved",
+ "badgeManual": "Manual",
+ "badgePending": "Awaiting approval",
+ "badgeRejected": "Rejected: {reason}",
+ "badgeCorrection": "Correction requested: {from}–{to}",
+ "reason": "Reason: {reason}",
+ "manualSession": "Recorded later",
+ "approve": "Approve",
+ "reject": "Reject",
+ "rejectReason": "Reason",
+ "approved": "Approved.",
+ "rejected": "Rejected.",
+ "openApprovals": "All open approvals"
},
"photos": {
"empty": "No photos yet.",
@@ -460,8 +474,35 @@
"running_session": "A work session is still running",
"field": {
"signature": "Signature missing",
- "approved_completion_report": "Completion report not approved yet"
+ "approved_completion_report": "Completion report not approved yet",
+ "pending_time_entries": "Time entries are still awaiting approval"
}
},
- "saved": "Saved."
+ "saved": "Saved.",
+ "timeApprovals": {
+ "crumb": "Work orders",
+ "title": "Time approvals",
+ "sub": "Added time and corrections by technicians. They count for reports and billing only after approval.",
+ "empty": "No open entries or corrections.",
+ "employee": "Employee",
+ "order": "Order",
+ "date": "Date",
+ "range": "From–to",
+ "duration": "Duration",
+ "type": "Type",
+ "reason": "Reason",
+ "kindManual": "Added entry",
+ "kindCorrection": "Correction",
+ "oldNew": "{old} → {new}",
+ "select": "Select",
+ "selectAll": "Select all",
+ "approveSelected": "Approve selected",
+ "approve": "Approve",
+ "reject": "Reject",
+ "rejectReason": "Reason for rejection",
+ "approved": "Approved.",
+ "bulkApproved": "{ok} approved, {failed} not possible.",
+ "rejected": "Rejected.",
+ "count": "{count, plural, one {# open entry} other {# open entries}}"
+ }
}
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index 0261dee..d828066 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -12,6 +12,7 @@ import {
Receipt,
RefreshCw,
Siren,
+ Timer,
Wrench,
type LucideIcon,
} from "lucide-react";
@@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record;
-const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [
+const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
@@ -39,6 +40,7 @@ const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[
{ key: "emergency_new", icon: Siren, tone: "var(--risk)" },
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
{ key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" },
+ { key: "time_approvals", icon: Timer, tone: "var(--warn)" },
];
/** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */
@@ -142,9 +144,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
- {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
- const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
- const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
+ {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => {
+ const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
+ const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
-
new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { hour: "2-digit", minute: "2-digit", timeZone: tz }).format(d);
+ const span = (s: Date, e: Date | null) => `${time(s)}–${e ? time(e) : "…"}`;
+
+ return (
+
+
+
+ {t("detail.back")}
+
+
+
+ {items.length === 0 ? (
+ {t("timeApprovals.empty")}
+ ) : (
+
+
+
{t("timeApprovals.count", { count: items.length })}
+
+
+
+
+
+
+ |
+
+ |
+ {t("timeApprovals.employee")} |
+ {t("timeApprovals.order")} |
+ {t("timeApprovals.date")} |
+ {t("timeApprovals.range")} |
+ {t("timeApprovals.duration")} |
+ {t("timeApprovals.type")} |
+ {t("timeApprovals.reason")} |
+ |
+
+
+
+ {items.map((i) => (
+
+ |
+
+ |
+
+ {i.userName}
+ {i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}
+ |
+
+
+ {i.number}
+
+ {i.title}
+ |
+ {formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)} |
+
+ {i.proposed ? (
+ t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) })
+ ) : (
+ span(i.startedAt, i.endedAt)
+ )}
+ |
+
+ {i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`}
+ |
+
+ {i.proposed && i.proposed.type !== i.type
+ ? t("timeApprovals.oldNew", { old: tf(`time.type.${i.type}`), new: tf(`time.type.${i.proposed.type}`) })
+ : tf(`time.type.${i.proposed?.type ?? i.type}`)}
+ |
+
+ {i.reason}
+ {i.note && {i.note} }
+ {formatDateTime(i.requestedAt, locale, tz)}
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/approvals/page.tsx b/src/app/(field)/m/(core)/approvals/page.tsx
new file mode 100644
index 0000000..2be1ca6
--- /dev/null
+++ b/src/app/(field)/m/(core)/approvals/page.tsx
@@ -0,0 +1,75 @@
+import Link from "next/link";
+import { getLocale, getTranslations } from "next-intl/server";
+import { can } from "@/server/services/context";
+import { fieldPageContext } from "@/server/services/field/page-context";
+import { listPendingTimeEntries } from "@/server/services/field/time-entries";
+import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format";
+import { card } from "@/components/field/ui";
+import { ApprovalActions } from "@/components/field/approval-actions";
+
+/** `/m/approvals` — team lead mobile (L12, `time:approve`): open manual entries and correction proposals of the team. */
+export default async function ApprovalsPage() {
+ const ctx = await fieldPageContext();
+ const t = await getTranslations("field");
+ const locale = await getLocale();
+ if (!can(ctx, "time:approve")) return {t("noAccess")}
;
+ const items = await listPendingTimeEntries(ctx);
+ const range = (s: Date, e: Date | null) => `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${e ? fmtTime(e, locale) : t("myTime.running")}`;
+
+ return (
+
+
+
{t("approvals.title")}
+
{t("approvals.sub")}
+
{t("approvals.count", { count: items.length })}
+
+ {items.length === 0 ? (
+ {t("approvals.empty")}
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx
index 565a21f..988534f 100644
--- a/src/app/(field)/m/(core)/orders/[id]/page.tsx
+++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx
@@ -112,12 +112,15 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length;
const checklistDone = order.checklistItems.filter((i) => i.checked).length;
const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length;
+ // L12: only approved entries count
const myWorkSeconds = order.workSessions
.filter((s) => s.userId === ctx.userId)
.flatMap((s) => s.entries)
- .filter((e) => e.type === "work")
+ .filter((e) => e.type === "work" && e.approvalStatus === "approved")
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
const editable = can(ctx, "field:execute");
+ const ownActive = order.workSessions.find((s) => s.userId === ctx.userId && !s.manual && s.status !== "ended");
+ const segmentType = ownActive?.entries.find((e) => !e.endedAt)?.type ?? null;
const quick = [
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
@@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
{editable && (
)}
diff --git a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx
index 9211595..2cd98a7 100644
--- a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx
+++ b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx
@@ -1,7 +1,13 @@
import { ReportScreen } from "@/components/reports/mobile/report-screen";
+import { PendingTimeNotice } from "@/components/field/pending-time-notice";
-/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */
+/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. L12: warning about unapproved time (not blocking). */
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx
index e5191b1..2ab6a9b 100644
--- a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx
+++ b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx
@@ -1,51 +1,75 @@
+import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
+import { Plus } from "lucide-react";
import { can } from "@/server/services/context";
+import { parsePendingChange } from "@/server/services/field/time-entries";
import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format";
import { SubPageHeader } from "@/components/field/sub-page-header";
import { TimeCorrectionForm } from "@/components/field/time-correction-form";
-import { card } from "@/components/field/ui";
+import { CorrectionProposalForm } from "@/components/field/correction-proposal-form";
+import { TimeEntryBadges } from "@/components/field/time-entry-badges";
+import { btnSecondary, card } from "@/components/field/ui";
import { loadOrder } from "../load";
-/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */
+/**
+ * `/m/orders/[id]/time` — sessions and time segments (Spec §12.2).
+ * L12: only approved entries count; own entries → „Korrektur vorschlagen" (approval), entries of
+ * team members → direct correction with `field:correct_time`; badges manual/pending/rejected.
+ */
export default async function TimePage({ params }: { params: Promise<{ id: string }> }) {
const { ctx, order } = await loadOrder(params);
const t = await getTranslations("field");
const locale = await getLocale();
const canCorrect = can(ctx, "field:correct_time");
- const totalWork = order.workSessions
- .flatMap((s) => s.entries)
- .filter((e) => e.type === "work")
- .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
+ const canPropose = can(ctx, "field:record_own_time") || canCorrect;
+ const entries = order.workSessions.flatMap((s) => s.entries);
+ const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
+ const pendingWork = entries.filter((e) => e.type !== "break" && e.approvalStatus === "pending").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
return (
{t("time.totalWork", { duration: fmtDuration(totalWork) })}
+ {pendingWork > 0 &&
{t("time.totalPending", { duration: fmtDuration(pendingWork) })}
}
+ {canPropose && (
+
+
+ {t("myTime.add")}
+
+ )}
{order.workSessions.length === 0 &&
{t("time.empty")}
}
{order.workSessions.map((s) => (
{s.user.name}
- {t(`time.session.${s.status}`)}
+ {s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)}
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
{fmtDateTime(s.startedAt, locale)}
- {s.entries.map((e) => (
- -
-
- {t(`time.type.${e.type}`)}
-
- {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
-
-
- {e.corrected && {t("time.corrected", { reason: e.correctionReason ?? "" })}
}
- {canCorrect && }
-
- ))}
+ {s.entries.map((e) => {
+ const own = e.userId === ctx.userId;
+ const pendingChange = parsePendingChange(e.pendingChange);
+ return (
+ -
+
+ {t(`time.type.${e.type}`)}
+
+ {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
+
+
+
+ {e.corrected && {t("time.corrected", { reason: e.correctionReason ?? "" })}
}
+ {own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && (
+
+ )}
+ {!own && canCorrect && }
+
+ );
+ })}
))}
diff --git a/src/app/(field)/m/(core)/orders/page.tsx b/src/app/(field)/m/(core)/orders/page.tsx
index d20248e..f8e6348 100644
--- a/src/app/(field)/m/(core)/orders/page.tsx
+++ b/src/app/(field)/m/(core)/orders/page.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { cn } from "@/lib/utils";
+import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card";
@@ -15,6 +16,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
const { tab: rawTab } = await searchParams;
const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running";
const orders = await listFieldOrders(ctx, tab);
+ const canExecute = can(ctx, "field:execute");
return (
@@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
{orders.map((o) => (
-
-
+
))}
diff --git a/src/app/(field)/m/(core)/page.tsx b/src/app/(field)/m/(core)/page.tsx
index 0802068..698ca65 100644
--- a/src/app/(field)/m/(core)/page.tsx
+++ b/src/app/(field)/m/(core)/page.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight } from "lucide-react";
+import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listTodayOrders } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card";
@@ -12,6 +13,7 @@ export default async function TodayPage() {
const t = await getTranslations("field");
if (!canUseFieldApp(ctx)) return {t("noAccess")}
;
+ const canExecute = can(ctx, "field:execute");
const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
return (
@@ -27,7 +29,7 @@ export default async function TodayPage() {
{orders.map((o) => (
-
-
+
))}
diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx
index 90ad296..32ad5a1 100644
--- a/src/app/(field)/m/(core)/profile/page.tsx
+++ b/src/app/(field)/m/(core)/profile/page.tsx
@@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth";
import { prisma } from "@/server/db";
import { can } from "@/server/services/context";
import { activeTeamIds } from "@/server/services/work-orders/visibility";
+import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { fieldPageContext } from "@/server/services/field/page-context";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { btnSecondary, card } from "@/components/field/ui";
@@ -15,7 +16,7 @@ export default async function ProfilePage() {
const ctx = await fieldPageContext();
const t = await getTranslations("field.profile");
const session = await auth();
- const teamIds = await activeTeamIds(ctx);
+ const [teamIds, approvals] = await Promise.all([activeTeamIds(ctx), countPendingTimeEntries(ctx)]);
const [me, teams, identity] = await Promise.all([
ctx.db.user.findFirst({
where: { id: ctx.userId },
@@ -58,6 +59,17 @@ export default async function ProfilePage() {
+ {can(ctx, "field:execute") && (
+
+ {t("myTime")}
+
+ )}
+ {can(ctx, "time:approve") && (
+
+ {t("approvals")}
+ {approvals > 0 && {approvals}}
+
+ )}
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
{t("backoffice")}
diff --git a/src/app/(field)/m/(core)/time/new/page.tsx b/src/app/(field)/m/(core)/time/new/page.tsx
new file mode 100644
index 0000000..ac20763
--- /dev/null
+++ b/src/app/(field)/m/(core)/time/new/page.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+import { ChevronLeft } from "lucide-react";
+import { can } from "@/server/services/context";
+import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
+import { getMyTimeOverview, listRecordableOrders, listRecordableUsers } from "@/server/services/field/time-entries";
+import { ManualTimeForm } from "@/components/field/manual-time-form";
+
+/** `/m/time/new` — „Zeit nachtragen" (L12). */
+export default async function NewTimePage({ searchParams }: { searchParams: Promise<{ date?: string; order?: string }> }) {
+ const ctx = await fieldPageContext();
+ const t = await getTranslations("field");
+ const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time");
+ if (!canUseFieldApp(ctx) || !canRecord) return {t("noAccess")}
;
+
+ const sp = await searchParams;
+ const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]);
+
+ return (
+
+
+
+ {t("myTime.title")}
+
+ {t("myTime.form.title")}
+ o.id === sp.order) ? sp.order! : null}
+ />
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/time/page.tsx b/src/app/(field)/m/(core)/time/page.tsx
new file mode 100644
index 0000000..804c85d
--- /dev/null
+++ b/src/app/(field)/m/(core)/time/page.tsx
@@ -0,0 +1,105 @@
+import Link from "next/link";
+import { getLocale, getTranslations } from "next-intl/server";
+import { ChevronRight, Plus } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { can } from "@/server/services/context";
+import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
+import { getMyTimeOverview } from "@/server/services/field/time-entries";
+import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format";
+import { btnPrimary, card, chip } from "@/components/field/ui";
+import { CorrectionProposalForm } from "@/components/field/correction-proposal-form";
+import { LocalPendingTimes } from "@/components/field/local-pending-times";
+import { TimeEntryBadges } from "@/components/field/time-entry-badges";
+
+/** `/m/time` — „Meine Zeiten" (L12): own entries of a day across all orders, approved vs. pending totals. */
+export default async function MyTimePage({ searchParams }: { searchParams: Promise<{ date?: string }> }) {
+ const ctx = await fieldPageContext();
+ const t = await getTranslations("field");
+ const locale = await getLocale();
+ if (!canUseFieldApp(ctx) || !can(ctx, "field:execute")) return {t("noAccess")}
;
+
+ const { date } = await searchParams;
+ const overview = await getMyTimeOverview(ctx, { date });
+ const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time");
+ const dayLabel = (key: string, i: number) => (i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${key}T12:00:00Z`, locale));
+
+ return (
+
+
+
{t("myTime.title")}
+
{t("myTime.sub")}
+
+
+
+
+
+
+
{t("myTime.approved")}
+
{fmtDuration(overview.approvedSeconds)}
+
+
+
{t("myTime.pending")}
+
0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}
+
+
+
+ {canRecord && (
+
+
+ {t("myTime.add")}
+
+ )}
+
+
+
+ {overview.groups.length === 0 ? (
+ {t("myTime.empty")}
+ ) : (
+ overview.groups.map((g) => (
+
+
+
+ {g.number}
+ {g.title}
+
+
+
+
+ {t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })}
+ {g.pendingSeconds > 0 && · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}}
+
+
+
+ ))
+ )}
+
+ );
+}
diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx
index 5c7619b..ca6f601 100644
--- a/src/app/(field)/m/layout.tsx
+++ b/src/app/(field)/m/layout.tsx
@@ -1,21 +1,45 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requireAppAccess } from "@/server/app-access";
+import { can } from "@/server/services/context";
+import { fieldPageContext } from "@/server/services/field/page-context";
+import { getMyActiveSession } from "@/server/services/field/sessions";
+import { countPendingTimeEntries } from "@/server/services/field/time-entries";
+import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge";
+import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime";
+/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
+async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
+ try {
+ const ctx = await fieldPageContext();
+ const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]);
+ return {
+ clock: session
+ ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
+ : null,
+ approvals,
+ };
+ } catch {
+ // module "field" disabled or no field permissions: shell without clock/badge
+ return { clock: null, approvals: 0 };
+ }
+}
+
/**
* Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice
* (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge.
* Module gates live one level below: (core) → "field", emergency → "emergency".
+ * L12: running clock bar above the bottom navigation on every /m page.
*/
export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) {
const access = await requireAppAccess();
if (access.kind === "inactive") return ;
- const t = await getTranslations("field.nav");
+ const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]);
return (
@@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
- {children}
-
+ {children}
+
+
);
}
diff --git a/src/components/field/approval-actions.tsx b/src/components/field/approval-actions.tsx
new file mode 100644
index 0000000..63029bb
--- /dev/null
+++ b/src/components/field/approval-actions.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Check, TriangleAlert, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { approveTime, rejectTime } from "@/server/actions/field/time";
+import { timeErrorKey } from "@/lib/field/time-rules";
+import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui";
+
+/** L12 team lead mobile: approve / reject (reason mandatory) with large buttons. */
+export function ApprovalActions({ timeEntryId }: { timeEntryId: string }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [rejecting, setRejecting] = useState(false);
+ const [reason, setReason] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function run(kind: "approve" | "reject") {
+ setError(null);
+ if (kind === "reject" && reason.trim().length < 3) {
+ setError(t("myTime.form.reasonHint"));
+ return;
+ }
+ setBusy(true);
+ const result = await (kind === "approve" ? approveTime({ timeEntryId }) : rejectTime({ timeEntryId, reason: reason.trim() })).catch(() => ({ ok: false as const, error: "failed" as const, message: undefined }));
+ setBusy(false);
+ if (!result.ok) {
+ const key = timeErrorKey({ message: result.message });
+ setError(key ? t(`myTime.errors.${key}`) : t(`errors.${result.error === "failed" ? "internal" : result.error}`));
+ return;
+ }
+ router.refresh();
+ }
+
+ return (
+
+ {!rejecting ? (
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/components/field/bottom-nav.tsx b/src/components/field/bottom-nav.tsx
index 96f90f5..da311fc 100644
--- a/src/components/field/bottom-nav.tsx
+++ b/src/components/field/bottom-nav.tsx
@@ -14,8 +14,11 @@ const ITEMS = [
{ href: "/m/profile", key: "profile", icon: User, exact: false },
] as const;
-/** Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. */
-export function BottomNav() {
+/**
+ * Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
+ * L12: badge counter of open time approvals on „Profil" (number + accessible label).
+ */
+export function BottomNav({ approvals = 0 }: { approvals?: number }) {
const t = useTranslations("field.nav");
const pathname = usePathname();
return (
@@ -23,6 +26,7 @@ export function BottomNav() {
{ITEMS.map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
+ const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
return (
-
-
+
+ {badge > 0 && (
+
+ {badge > 99 ? "99+" : badge}
+
+ )}
{t(item.key)}
+ {badge > 0 && {t("approvalsBadge", { count: badge })}}
);
diff --git a/src/components/field/card-time-button.tsx b/src/components/field/card-time-button.tsx
new file mode 100644
index 0000000..ac89f61
--- /dev/null
+++ b/src/components/field/card-time-button.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { LoaderCircle, Pause, Play, TriangleAlert, Wrench } from "lucide-react";
+import type { WorkOrderStatus } from "@/lib/work-orders/status";
+import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
+import { btnSecondary, noticeError } from "./ui";
+import { useSessionStart } from "./switch-session-sheet";
+
+const STARTABLE: WorkOrderStatus[] = ["accepted", "en_route", "in_progress", "paused", "waiting_material", "daily_report_created"];
+
+/** L12: direct start / pause / resume on an order card (Heute, Aufträge) without opening the order. */
+export function CardTimeButton({ workOrderId, number, status, mySession }: { workOrderId: string; number: string; status: WorkOrderStatus; mySession: "en_route" | "running" | "paused" | null }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [pausing, setPausing] = useState(false);
+ const [error, setError] = useState(null);
+ const { start, busy, sheet } = useSessionStart(workOrderId, number, (r) => setError(isSuccess(r) ? null : t(`errors.${errorKey(r)}`)));
+
+ const action: "start" | "pause" | "resume" | null =
+ mySession === "running" ? "pause" : mySession === "paused" ? "resume" : mySession === "en_route" || STARTABLE.includes(status) ? "start" : null;
+ if (!action) return null;
+
+ async function run() {
+ setError(null);
+ if (action === "pause") {
+ setPausing(true);
+ const r = await submitOp({ opType: "session.pause", payload: { workOrderId, at: new Date().toISOString() } });
+ setPausing(false);
+ if (!isSuccess(r)) setError(t(`errors.${errorKey(r)}`));
+ router.refresh();
+ return;
+ }
+ await start(action === "resume" ? "resume" : "work");
+ }
+
+ const loading = pausing || busy !== null;
+ const Icon = loading ? LoaderCircle : action === "pause" ? Pause : action === "resume" ? Play : Wrench;
+ return (
+ <>
+
+ {error && (
+
+
+ {error}
+
+ )}
+ {sheet}
+ >
+ );
+}
diff --git a/src/components/field/correction-proposal-form.tsx b/src/components/field/correction-proposal-form.tsx
new file mode 100644
index 0000000..b011e15
--- /dev/null
+++ b/src/components/field/correction-proposal-form.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { PenLine, TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { errorKey, isQueued, isSuccess, submitOp } from "@/lib/field/client-ops";
+import { MANUAL_TIME_TYPES, REASON_SUGGESTIONS, timeErrorKey } from "@/lib/field/time-rules";
+import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
+
+/** ISO → value of in the device time zone. */
+export function toLocalInput(iso: string): string {
+ const d = new Date(iso);
+ return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
+}
+
+/**
+ * L12 „Korrektur vorschlagen" for an own entry (instead of direct editing): the old values stay
+ * valid until a team lead / the office approves. Sent as sync op (works offline).
+ */
+export function CorrectionProposalForm({ workOrderId, entry }: { workOrderId: string; entry: { id: string; type: string; startedAt: string; endedAt: string | null } }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [start, setStart] = useState("");
+ const [end, setEnd] = useState("");
+ const [type, setType] = useState(entry.type);
+ const [reason, setReason] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(null);
+
+ if (!open) {
+ return (
+ <>
+
+ {done && (
+
+ {done}
+
+ )}
+ >
+ );
+ }
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ if (reason.trim().length < 3) {
+ setError(t("myTime.form.reasonHint"));
+ return;
+ }
+ if (!start || !end) {
+ setError(t("errors.invalid"));
+ return;
+ }
+ setBusy(true);
+ const result = await submitOp({
+ opType: "time.propose_correction",
+ payload: { workOrderId, timeEntryId: entry.id, type: type as (typeof MANUAL_TIME_TYPES)[number], startedAt: new Date(start).toISOString(), endedAt: new Date(end).toISOString(), reason: reason.trim() },
+ });
+ setBusy(false);
+ if (!isSuccess(result)) {
+ const key = timeErrorKey(result);
+ setError(key ? t(`myTime.errors.${key}`) : t(`errors.${errorKey(result)}`));
+ return;
+ }
+ setOpen(false);
+ setReason("");
+ setDone(isQueued(result) ? t("myTime.form.queued") : t("myTime.correction.saved"));
+ router.refresh();
+ }
+
+ const types: readonly string[] = (MANUAL_TIME_TYPES as readonly string[]).includes(entry.type) ? MANUAL_TIME_TYPES : [...MANUAL_TIME_TYPES, entry.type];
+ return (
+
+ );
+}
+
+/** Mandatory reason with suggestion chips („Start vergessen", „Kein Netz", „Nachträglich erfasst"). */
+export function ReasonField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const t = useTranslations("field.myTime");
+ return (
+
+
+
+ {REASON_SUGGESTIONS.map((key) => {
+ const text = t(`reasons.${key}`);
+ return (
+
+ );
+ })}
+
+
{t("form.reasonHint")}
+
+ );
+}
diff --git a/src/components/field/local-pending-times.tsx b/src/components/field/local-pending-times.tsx
new file mode 100644
index 0000000..e28029d
--- /dev/null
+++ b/src/components/field/local-pending-times.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { CloudOff } from "lucide-react";
+import { fmtDateTime } from "@/lib/field/format";
+import { listOutbox, subscribeOffline } from "@/lib/offline/outbox";
+import { isPending } from "@/lib/offline/outbox-core";
+import type { OutboxEntry } from "@/lib/offline/types";
+import { card } from "./ui";
+
+/**
+ * L12 optimistic view: manual time entries and „Für heute beenden" stored on this device but not
+ * yet transmitted (offline outbox) — shown with a pending badge until the server confirms them.
+ */
+export function LocalPendingTimes() {
+ const t = useTranslations("field");
+ const locale = useLocale();
+ const [ops, setOps] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = () =>
+ void listOutbox()
+ .then((l) => {
+ if (!cancelled) setOps(l.ops.filter((o) => isPending(o) && (o.opType === "time.add_manual" || o.opType === "time.propose_correction" || o.opType === "session.stop_day")));
+ })
+ .catch(() => undefined);
+ load();
+ const unsubscribe = subscribeOffline(load);
+ return () => {
+ cancelled = true;
+ unsubscribe();
+ };
+ }, []);
+
+ if (!ops.length) return null;
+ return (
+
+
+
+ {t("myTime.localTitle")}
+
+
+ {ops.map((o) => {
+ const p = o.payload as { type?: string; startedAt?: string; endedAt?: string; durationMinutes?: number; at?: string };
+ const label =
+ o.opType === "session.stop_day" ? t("action.stop_day") : o.opType === "time.propose_correction" ? t("time.propose") : `${t("myTime.add")} · ${t(`time.type.${p.type ?? "work"}`)}`;
+ const when = p.startedAt ?? p.at ?? o.clientCreatedAt;
+ return (
+ -
+ {label}
+
+ {fmtDateTime(when, locale)}
+ {p.durationMinutes ? ` · ${p.durationMinutes} min` : ""}
+
+ {t("myTime.localPending")}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/field/manual-time-form.tsx b/src/components/field/manual-time-form.tsx
new file mode 100644
index 0000000..2cf64cf
--- /dev/null
+++ b/src/components/field/manual-time-form.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useLocale, useTranslations } from "next-intl";
+import { Info, TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { fmtDate } from "@/lib/field/format";
+import { errorKey, isQueued, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { DURATION_QUICK_PICKS, MANUAL_TIME_TYPES, MAX_ENTRY_MINUTES, timeErrorKey } from "@/lib/field/time-rules";
+import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk, noticeWarn } from "./ui";
+import { ReasonField } from "./correction-proposal-form";
+
+type Option = { id: string; number: string; title: string };
+
+/**
+ * L12 „Zeit nachtragen": order, type (chips), date, from–to OR duration (quick picks 15/30/60),
+ * mandatory reason with suggestions. Own entries are pending until approved; with
+ * `field:correct_time` time can be recorded for team members (approved directly).
+ * Sent through the offline outbox (op `time.add_manual`).
+ */
+export function ManualTimeForm({ orders, users, days, defaultDate, defaultWorkOrderId }: { orders: Option[]; users: Array<{ id: string; name: string }>; days: string[]; defaultDate: string; defaultWorkOrderId: string | null }) {
+ const t = useTranslations("field");
+ const locale = useLocale();
+ const router = useRouter();
+ const [workOrderId, setWorkOrderId] = useState(defaultWorkOrderId ?? "");
+ const [forUserId, setForUserId] = useState("");
+ const [type, setType] = useState<(typeof MANUAL_TIME_TYPES)[number]>("work");
+ const [date, setDate] = useState(defaultDate);
+ const [mode, setMode] = useState<"range" | "duration">("range");
+ const [from, setFrom] = useState("08:00");
+ const [to, setTo] = useState("09:00");
+ const [duration, setDuration] = useState(30);
+ const [reason, setReason] = useState("");
+ const [note, setNote] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(null);
+
+ if (orders.length === 0) return {t("myTime.form.noOrders")}
;
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setDone(null);
+ if (!workOrderId || reason.trim().length < 3) {
+ setError(!workOrderId ? t("myTime.form.orderPlaceholder") : t("myTime.form.reasonHint"));
+ return;
+ }
+ // wall time of the device (same zone as the displayed times)
+ const start = new Date(`${date}T${from}`);
+ const end = mode === "range" ? new Date(`${date}T${to}`) : null;
+ if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
+ setError(t("errors.invalid"));
+ return;
+ }
+ setBusy(true);
+ const result = await submitOp({
+ opType: "time.add_manual",
+ payload: {
+ workOrderId,
+ clientId: newClientId(),
+ type,
+ startedAt: start.toISOString(),
+ ...(end ? { endedAt: end.toISOString() } : { durationMinutes: duration }),
+ reason: reason.trim(),
+ ...(note.trim() ? { note: note.trim() } : {}),
+ ...(forUserId ? { forUserId } : {}),
+ },
+ });
+ setBusy(false);
+ if (!isSuccess(result)) {
+ const key = timeErrorKey(result);
+ setError(key ? t(`myTime.errors.${key}`) : t(`errors.${errorKey(result)}`));
+ return;
+ }
+ if (isQueued(result)) {
+ setDone(t("myTime.form.queued"));
+ setReason("");
+ return;
+ }
+ router.push(`/m/time?date=${date}`);
+ router.refresh();
+ }
+
+ const label = "mb-1.5 block text-[14px] font-semibold";
+ return (
+
+ );
+}
diff --git a/src/components/field/order-card.tsx b/src/components/field/order-card.tsx
index 017c6a3..220f8b2 100644
--- a/src/components/field/order-card.tsx
+++ b/src/components/field/order-card.tsx
@@ -1,23 +1,37 @@
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
-import { ChevronRight, Clock, MapPin, Navigation, Siren } from "lucide-react";
+import { ChevronRight, Clock, MapPin, Navigation, Siren, Timer } from "lucide-react";
import { cn } from "@/lib/utils";
import { fmtWindow } from "@/lib/field/format";
import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
import { StatusBadge } from "./status-badge";
-import { btnPrimary, toneClasses } from "./ui";
+import { btnPrimary, btnSecondary, toneClasses } from "./ui";
+import { CardTimeButton } from "./card-time-button";
/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
-export function OrderCard({ order }: { order: OrderCardData }) {
+export function OrderCard({ order, canExecute = false }: { order: OrderCardData; canExecute?: boolean }) {
const t = useTranslations("field.card");
const locale = useLocale();
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
+ const clock = order.mySession;
return (
-
+
{order.number}
+ {clock && (
+
+
+ {clock === "paused" ? t("paused") : clock === "en_route" ? t("enRoute") : t("running")}
+
+ )}
{order.title}
{order.customerName}
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
@@ -52,10 +66,13 @@ export function OrderCard({ order }: { order: OrderCardData }) {
)}
-
- {t("open")}
-
-
+
+ {canExecute && }
+
+ {t("open")}
+
+
+
);
}
diff --git a/src/components/field/pending-time-notice.tsx b/src/components/field/pending-time-notice.tsx
new file mode 100644
index 0000000..869d8f7
--- /dev/null
+++ b/src/components/field/pending-time-notice.tsx
@@ -0,0 +1,40 @@
+import { getTranslations } from "next-intl/server";
+import { Clock } from "lucide-react";
+import { fmtDuration } from "@/lib/field/format";
+import { fieldPageContext } from "@/server/services/field/page-context";
+import { pendingTimeOfOrder } from "@/server/services/field/time-entries";
+import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
+import { noticeWarn } from "./ui";
+
+async function loadPending(workOrderId: string) {
+ try {
+ const ctx = await fieldPageContext();
+ await requireVisibleWorkOrder(ctx, workOrderId, { id: true });
+ return await pendingTimeOfOrder(ctx, workOrderId);
+ } catch {
+ // not visible / no field context: the report screen handles access itself
+ return null;
+ }
+}
+
+/**
+ * L12: warns (does not block) before submitting a report while manual time entries or correction
+ * proposals of the order still wait for approval — they are not part of the report totals.
+ */
+export async function PendingTimeNotice({ workOrderId }: { workOrderId: string }) {
+ const pending = await loadPending(workOrderId);
+ if (!pending || (pending.pendingEntries === 0 && pending.openProposals === 0)) return null;
+ const t = await getTranslations("field.pendingTime");
+ return (
+
+
+
+
+ {pending.pendingEntries > 0 && t("report", { duration: fmtDuration(pending.pendingMinutes * 60) })}
+ {pending.pendingEntries > 0 && pending.openProposals > 0 && " "}
+ {pending.openProposals > 0 && t("proposals", { count: pending.openProposals })}
+
+
+
+ );
+}
diff --git a/src/components/field/primary-action.tsx b/src/components/field/primary-action.tsx
index 157779f..30a8bdc 100644
--- a/src/components/field/primary-action.tsx
+++ b/src/components/field/primary-action.tsx
@@ -3,90 +3,93 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
-import { CircleCheck, LoaderCircle, Pause, Play, TriangleAlert, Truck, Wrench, Handshake } from "lucide-react";
+import { CircleCheck, LoaderCircle, Package, Pause, Play, Square, TriangleAlert, Truck, Undo2, Wrench, Handshake } from "lucide-react";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
-import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
-import { currentPosition } from "@/lib/field/image";
+import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
+import { cn } from "@/lib/utils";
import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
+import { useSessionStart } from "./switch-session-sheet";
type SessionState = "en_route" | "running" | "paused" | null;
-type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "complete";
+type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "stop_day" | "complete";
+type SegmentKey = "work" | "return_travel" | "material_procurement";
const WORKING: WorkOrderStatus[] = ["in_progress", "paused", "waiting_material", "daily_report_created"];
-/** One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter → Abschließen. */
-export function resolveActions(status: WorkOrderStatus, mySession: SessionState): { primary: ActionKey | null; secondary: ActionKey | null } {
- if (status === "assigned") return { primary: "accept", secondary: null };
- if (status === "accepted") return { primary: "travel", secondary: "start" };
- if (status === "en_route") return { primary: "start", secondary: null };
+/**
+ * One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter.
+ * L12: with an own session the primary action is Pause (or Weiter), secondary „Für heute beenden",
+ * „Abschließen" as a separate text link (`link`) — max. 2 large buttons + 1 link.
+ */
+export function resolveActions(
+ status: WorkOrderStatus,
+ mySession: SessionState,
+): { primary: ActionKey | null; secondary: ActionKey | null; link: "complete" | null } {
+ if (status === "assigned") return { primary: "accept", secondary: null, link: null };
+ if (status === "accepted") return { primary: "travel", secondary: "start", link: null };
+ if (status === "en_route") return { primary: "start", secondary: mySession === "en_route" ? "stop_day" : null, link: null };
if (WORKING.includes(status)) {
- if (mySession === "running") return { primary: "complete", secondary: "pause" };
- if (mySession === "paused") return { primary: "resume", secondary: "complete" };
- return { primary: "start", secondary: status === "in_progress" ? "complete" : null };
+ if (mySession === "running") return { primary: "pause", secondary: "stop_day", link: "complete" };
+ if (mySession === "paused") return { primary: "resume", secondary: "stop_day", link: "complete" };
+ if (mySession === "en_route") return { primary: "start", secondary: "stop_day", link: null };
+ return { primary: "start", secondary: null, link: status === "in_progress" ? "complete" : null };
}
- return { primary: null, secondary: null };
+ return { primary: null, secondary: null, link: null };
}
-const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, complete: CircleCheck } as const;
-
-async function sessionStartPayload(workOrderId: string, mode: "travel" | "work") {
- const pos = await currentPosition(3000);
- return {
- workOrderId,
- mode,
- clientId: newClientId(),
- at: new Date().toISOString(),
- offline: typeof navigator !== "undefined" ? !navigator.onLine : false,
- deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined,
- ...(pos ?? {}),
- };
-}
+const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, stop_day: Square, complete: CircleCheck } as const;
+const SEGMENT_ICONS = { work: Undo2, return_travel: Truck, material_procurement: Package } as const;
export function PrimaryAction({
workOrderId,
+ number,
status,
version,
mySession,
+ segmentType,
blockers,
}: {
workOrderId: string;
+ number: string;
status: WorkOrderStatus;
version: number;
mySession: SessionState;
+ /** type of the open segment of the own session (L12 segment switch) */
+ segmentType?: string | null;
/** translated completion blockers (without the user's own session, which is ended on completion) */
blockers: string[];
}) {
const t = useTranslations("field");
const router = useRouter();
- const [busy, setBusy] = useState(null);
+ const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
- const [confirm, setConfirm] = useState(false);
- const { primary, secondary } = resolveActions(status, mySession);
+ const [confirm, setConfirm] = useState<"complete" | "stop_day" | null>(null);
+ const { primary, secondary, link } = resolveActions(status, mySession);
+ const starter = useSessionStart(workOrderId, number, (r) => setError(isSuccess(r) ? null : t(`errors.${errorKey(r)}`)));
async function run(action: ActionKey) {
- if (action === "complete" && !confirm) {
- setConfirm(true);
+ if ((action === "complete" || action === "stop_day") && confirm !== action) {
+ setConfirm(action);
+ return;
+ }
+ setError(null);
+ if (action === "travel" || action === "start" || action === "resume") {
+ setConfirm(null);
+ await starter.start(action === "travel" ? "travel" : action === "resume" ? "resume" : "work");
return;
}
setBusy(action);
- setError(null);
const at = new Date().toISOString();
let result;
switch (action) {
case "accept":
result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } });
break;
- case "travel":
- result = await submitOp({ opType: "session.start", payload: await sessionStartPayload(workOrderId, "travel") });
- break;
- case "start":
- result = await submitOp({ opType: "session.start", payload: await sessionStartPayload(workOrderId, "work") });
- break;
case "pause":
result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } });
break;
- case "resume":
- result = await submitOp({ opType: "session.resume", payload: { workOrderId, at } });
+ case "stop_day":
+ result = await submitOp({ opType: "session.stop_day", payload: { workOrderId, at } });
break;
case "complete": {
let base = version;
@@ -103,7 +106,16 @@ export function PrimaryAction({
}
}
setBusy(null);
- setConfirm(false);
+ setConfirm(null);
+ if (result && !isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
+ router.refresh();
+ }
+
+ async function segment(type: SegmentKey) {
+ setBusy(type);
+ setError(null);
+ const result = await submitOp({ opType: "session.segment", payload: { workOrderId, type, at: new Date().toISOString() } });
+ setBusy(null);
if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
router.refresh();
}
@@ -113,33 +125,83 @@ export function PrimaryAction({
}
const completeBlocked = blockers.length > 0;
+ const anyBusy = busy !== null || starter.busy !== null;
const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
- const Icon = busy === action ? LoaderCircle : ICONS[action];
- const disabled = busy !== null || (action === "complete" && completeBlocked);
+ const loading = busy === action || (starter.busy !== null && (action === "travel" || action === "start" || action === "resume"));
+ const Icon = loading ? LoaderCircle : ICONS[action];
+ const disabled = anyBusy || (action === "complete" && completeBlocked);
+ const label = loading ? t("action.saving") : confirm === action ? t(action === "complete" ? "action.confirmComplete" : "action.confirmStopDay") : t(`action.${action}`);
return (
);
};
+ // segment switches (smaller actions) while the own session is running or paused
+ const segments: SegmentKey[] =
+ mySession === "running" || mySession === "paused"
+ ? (["return_travel", "material_procurement", "work"] as SegmentKey[]).filter((s) => (s === "work" ? mySession === "running" && !!segmentType && segmentType !== "work" : s !== segmentType))
+ : [];
+
return (
{renderButton(primary, "primary")}
{secondary && renderButton(secondary, "secondary")}
- {confirm && (
+ {segments.length > 0 && (
+
+ {mySession === "running" && segmentType && segmentType !== "work" && (
+
{t("action.currentSegment", { type: t(`time.type.${segmentType}`) })}
+ )}
+ {segments.map((s) => {
+ const Icon = busy === s ? LoaderCircle : SEGMENT_ICONS[s];
+ return (
+
+ );
+ })}
+
+ )}
+ {link === "complete" && confirm !== "complete" && (
+
+ )}
+ {confirm === "complete" && (
+ <>
+ {renderButton("complete", "secondary")}
+
+
+
+ {t("action.confirmHint")}{" "}
+
+
+
+ >
+ )}
+ {confirm === "stop_day" && (
- {t("action.confirmHint")}{" "}
-
)}
- {completeBlocked && (primary === "complete" || secondary === "complete") && (
+ {completeBlocked && (primary === "complete" || secondary === "complete" || link === "complete") && (
@@ -158,6 +220,7 @@ export function PrimaryAction({
{error}
)}
+ {starter.sheet}
);
}
diff --git a/src/components/field/running-clock-bar.tsx b/src/components/field/running-clock-bar.tsx
new file mode 100644
index 0000000..ec3ebce
--- /dev/null
+++ b/src/components/field/running-clock-bar.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { History, LoaderCircle, Pause, Play, Square } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { isSuccess, submitOp } from "@/lib/field/client-ops";
+import { subscribeOffline } from "@/lib/offline/outbox";
+import { readOrders } from "@/lib/offline/read";
+import type { MyActiveSession } from "@/server/services/field/sessions";
+import { useSessionStart } from "./switch-session-sheet";
+
+export type ClockSession = Pick
& { segmentType: string | null };
+
+/** "1:23 h" */
+function fmtClock(seconds: number): string {
+ const total = Math.max(0, Math.floor(seconds / 60));
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} h`;
+}
+
+function elapsed(s: ClockSession, now: number): number {
+ const running = s.status !== "paused" && s.segmentStartedAt && s.segmentType !== "break" ? Math.max(0, (now - new Date(s.segmentStartedAt).getTime()) / 1000) : 0;
+ return s.closedSeconds + running;
+}
+
+/** Offline fallback: the own session from the local bundle + queued ops (no durations available locally). */
+async function localSession(): Promise {
+ const read = await readOrders().catch(() => null);
+ if (!read?.ready) return null;
+ const order = read.orders.find((o) => o.local.session === "running" || o.local.session === "en_route") ?? read.orders.find((o) => o.local.session === "paused");
+ if (!order) return null;
+ return {
+ status: order.local.session as ClockSession["status"],
+ workOrderId: order.id,
+ number: order.number,
+ title: order.title,
+ segmentStartedAt: order.mySession?.startedAt ?? null,
+ segmentType: order.local.session === "paused" ? "break" : "work",
+ closedSeconds: 0,
+ };
+}
+
+/**
+ * L12 running clock: fixed above the bottom navigation on every /m page while an own session runs
+ * or is paused — „A-00042 · Wärmepumpe · 1:23 h", Pause/Weiter, „Für heute beenden", tap → order.
+ * Server data on every render (router.refresh after actions); offline from the local state.
+ */
+export function RunningClockBar({ initial }: { initial: ClockSession | null }) {
+ const t = useTranslations("field.clock");
+ const router = useRouter();
+ const [now, setNow] = useState(() => Date.now());
+ const [offlineSession, setOfflineSession] = useState(undefined);
+ const [busy, setBusy] = useState<"pause" | "stop" | null>(null);
+ const [confirmStop, setConfirmStop] = useState(false);
+
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const refresh = () => {
+ if (typeof navigator === "undefined" || navigator.onLine) {
+ setOfflineSession(undefined);
+ return;
+ }
+ void localSession().then((s) => {
+ if (!cancelled) setOfflineSession(s);
+ });
+ };
+ refresh();
+ const unsubscribe = subscribeOffline(refresh);
+ window.addEventListener("online", refresh);
+ window.addEventListener("offline", refresh);
+ return () => {
+ cancelled = true;
+ unsubscribe();
+ window.removeEventListener("online", refresh);
+ window.removeEventListener("offline", refresh);
+ };
+ }, []);
+
+ const session = offlineSession === undefined ? initial : offlineSession;
+ const resume = useSessionStart(session?.workOrderId ?? "", session?.number ?? "");
+ if (!session) return null;
+ const offline = offlineSession !== undefined;
+
+ async function pause() {
+ if (!session) return;
+ setBusy("pause");
+ await submitOp({ opType: "session.pause", payload: { workOrderId: session.workOrderId, at: new Date().toISOString() } });
+ setBusy(null);
+ router.refresh();
+ }
+
+ async function stopDay() {
+ if (!session) return;
+ if (!confirmStop) {
+ setConfirmStop(true);
+ return;
+ }
+ setBusy("stop");
+ const r = await submitOp({ opType: "session.stop_day", payload: { workOrderId: session.workOrderId, at: new Date().toISOString() } });
+ setBusy(null);
+ setConfirmStop(false);
+ if (isSuccess(r)) router.refresh();
+ }
+
+ const statusLabel = session.status === "paused" ? t("paused") : session.status === "en_route" ? t("enRoute") : t("running");
+ const btn = "inline-flex min-h-12 min-w-12 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-[14px] font-semibold disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
+
+ return (
+
+
+
+
+
+ {statusLabel}
+ {offline && ` · ${t("offline")}`}
+
+
+ {session.number} · {session.title}
+ {!offline && · {fmtClock(elapsed(session, now))}}
+
+
+
+
+
+ {session.status === "paused" ? (
+
resume.start("resume")}>
+ {resume.busy ? : }
+ {t("resume")}
+
+ ) : session.status === "running" ? (
+
+ {busy === "pause" ? : }
+ {t("pause")}
+
+ ) : null}
+
setConfirmStop(false)}
+ aria-label={t("stopDay")}
+ >
+ {busy === "stop" ? : }
+ {confirmStop ? t("stopDayConfirm") : t("stopDay")}
+
+
+ {resume.sheet}
+
+ );
+}
diff --git a/src/components/field/switch-session-sheet.tsx b/src/components/field/switch-session-sheet.tsx
new file mode 100644
index 0000000..f7c3fe0
--- /dev/null
+++ b/src/components/field/switch-session-sheet.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { ArrowLeftRight, LoaderCircle } from "lucide-react";
+import type { SyncOpResult } from "@/lib/sync/envelope";
+import { isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { currentPosition } from "@/lib/field/image";
+import { otherSessionOf, type OtherSession } from "@/lib/field/time-rules";
+import { btnPrimary, btnSecondary } from "./ui";
+
+type StartKind = "travel" | "work" | "resume";
+
+async function startPayload(workOrderId: string, mode: "travel" | "work", switchFromOther: boolean) {
+ const pos = await currentPosition(3000);
+ return {
+ workOrderId,
+ mode,
+ clientId: newClientId(),
+ at: new Date().toISOString(),
+ offline: typeof navigator !== "undefined" ? !navigator.onLine : false,
+ deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined,
+ ...(switchFromOther ? { switchFromOther: true } : {}),
+ ...(pos ?? {}),
+ };
+}
+
+/** Sends session.start / session.resume (optionally with the auto switch flag). */
+export async function submitStart(workOrderId: string, kind: StartKind, switchFromOther = false): Promise {
+ if (kind === "resume") {
+ return submitOp({ opType: "session.resume", payload: { workOrderId, at: new Date().toISOString(), ...(switchFromOther ? { switchFromOther: true } : {}) } });
+ }
+ return submitOp({ opType: "session.start", payload: await startPayload(workOrderId, kind, switchFromOther) });
+}
+
+/**
+ * L12 auto switch: start/resume work; when another order is still on the clock the server answers
+ * `other_session_running` and the bottom sheet asks „A-00041 läuft noch. Pausieren und A-00042 starten?".
+ */
+export function useSessionStart(workOrderId: string, number: string, onDone?: (result: SyncOpResult) => void) {
+ const router = useRouter();
+ const [busy, setBusy] = useState(null);
+ const [pending, setPending] = useState<{ kind: StartKind; other: OtherSession } | null>(null);
+ const [result, setResult] = useState(null);
+
+ async function start(kind: StartKind, switchFromOther = false) {
+ setBusy(kind);
+ const r = await submitStart(workOrderId, kind, switchFromOther);
+ setBusy(null);
+ const other = otherSessionOf(r);
+ if (other && !switchFromOther) {
+ setPending({ kind, other });
+ return r;
+ }
+ setPending(null);
+ setResult(r);
+ onDone?.(r);
+ if (isSuccess(r)) router.refresh();
+ return r;
+ }
+
+ const sheet = pending ? (
+ start(pending.kind, true)}
+ onCancel={() => setPending(null)}
+ />
+ ) : null;
+
+ return { start, busy, result, sheet };
+}
+
+export function SwitchSessionSheet({ from, to, busy, onConfirm, onCancel }: { from: string; to: string; busy: boolean; onConfirm: () => void; onCancel: () => void }) {
+ const t = useTranslations("field.switch");
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+ {t("title")}
+
+
{from ? t("text", { from, to }) : t("textUnknown", { to })}
+
+
+
+ {busy ? : }
+ {t("confirm")}
+
+
+ {t("cancel")}
+
+
+
+ );
+}
diff --git a/src/components/field/time-entry-badges.tsx b/src/components/field/time-entry-badges.tsx
new file mode 100644
index 0000000..64b975e
--- /dev/null
+++ b/src/components/field/time-entry-badges.tsx
@@ -0,0 +1,72 @@
+import { useLocale, useTranslations } from "next-intl";
+import { CircleCheck, Clock, PenLine, TriangleAlert } from "lucide-react";
+import { fmtTime } from "@/lib/field/format";
+
+type BadgeEntry = {
+ source: "tracked" | "manual";
+ approvalStatus: "approved" | "pending" | "rejected";
+ rejectionReason: string | null;
+ corrected: boolean;
+ pendingChange: { startedAt: string; endedAt: string; type: string } | null;
+};
+
+const badge = "inline-flex min-h-7 items-center gap-1 rounded-lg px-2 text-[13px] font-semibold";
+
+/** L12 status badges of a time entry — always text + icon, never colour alone. */
+export function TimeEntryBadges({ entry }: { entry: BadgeEntry }) {
+ const t = useTranslations("field.myTime.badge");
+ const tt = useTranslations("field");
+ const locale = useLocale();
+ const items: React.ReactNode[] = [];
+ if (entry.source === "manual") {
+ items.push(
+
+
+ {t("manual")}
+ ,
+ );
+ }
+ if (entry.approvalStatus === "pending") {
+ items.push(
+
+
+ {t("pending")}
+ ,
+ );
+ }
+ if (entry.approvalStatus === "rejected") {
+ items.push(
+
+
+ {t("rejected", { reason: entry.rejectionReason ?? "" })}
+ ,
+ );
+ }
+ if (entry.approvalStatus === "approved" && entry.pendingChange) {
+ items.push(
+
+
+ {t("correctionRequested")} ·{" "}
+ {tt("myTime.proposed", { from: fmtTime(entry.pendingChange.startedAt, locale), to: fmtTime(entry.pendingChange.endedAt, locale), type: tt(`time.type.${entry.pendingChange.type}`) })}
+ ,
+ );
+ }
+ if (entry.approvalStatus === "approved" && !entry.pendingChange && entry.rejectionReason) {
+ items.push(
+
+
+ {t("correctionRejected", { reason: entry.rejectionReason })}
+ ,
+ );
+ }
+ if (entry.corrected) {
+ items.push(
+
+
+ {t("corrected")}
+ ,
+ );
+ }
+ if (!items.length) return null;
+ return {items}
;
+}
diff --git a/src/components/offline/offline-view.tsx b/src/components/offline/offline-view.tsx
index e1412a1..e0cc4f7 100644
--- a/src/components/offline/offline-view.tsx
+++ b/src/components/offline/offline-view.tsx
@@ -399,18 +399,20 @@ function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () =>
);
}
-type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end";
+type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end" | "stop_day";
function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
+ const tf = useTranslations("field");
const { feedback, report } = useFeedback();
const [busy, setBusy] = useState(null);
const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session);
const actions = [resolved.primary, resolved.secondary]
.map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a))
.filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i);
- const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete";
- const labels: Record = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd") };
+ // L12: „Abschließen" is a text link in resolveActions; offline it stays a hint
+ const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete" || resolved.link === "complete";
+ const labels: Record = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd"), stop_day: tf("action.stop_day") };
async function run(action: Action) {
setBusy(action);
@@ -423,7 +425,7 @@ function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () =>
? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } })
: action === "travel" || action === "start"
? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") })
- : await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : "session.end", payload: { workOrderId, at } });
+ : await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : action === "stop_day" ? "session.stop_day" : "session.end", payload: { workOrderId, at } });
setBusy(null);
report(result);
onChanged();
diff --git a/src/components/work-orders/detail-tabs.tsx b/src/components/work-orders/detail-tabs.tsx
index ac9dbdf..51cb57a 100644
--- a/src/components/work-orders/detail-tabs.tsx
+++ b/src/components/work-orders/detail-tabs.tsx
@@ -27,6 +27,7 @@ import {
removePhotoRequirementAction,
} from "@/server/actions/work_orders/work-orders";
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
+import { approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
import { ActionForm } from "@/components/work-orders/action-form";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
@@ -300,10 +301,35 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
const t = await getTranslations("workOrders");
const sessions = await getTimesTab(ctx, wo.id);
if (sessions.length === 0) return {t("times.empty")};
+ // L12: approved vs. pending totals, badges and inline approval
+ const canApprove = ctx.permissions.has("time:approve");
+ const pendingTotal = sessions.reduce((sum, s) => sum + s.pendingMinutes, 0);
+ const openDecisions = sessions.flatMap((s) => s.entries).filter((e) => e.approvalStatus === "pending" || e.pendingChange !== null).length;
+ const badge = "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold";
return (
+
+
+ {t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
+
+ {pendingTotal > 0 && {t("times.pendingMinutes", { minutes: pendingTotal })}}
+ {canApprove && openDecisions > 0 && (
+
+ {t("times.openApprovals")}
+
+ )}
+
{sessions.map((s) => (
-
{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}}>
+
+ {t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}
+ {s.pendingMinutes > 0 && {t("times.pendingMinutes", { minutes: s.pendingMinutes })}}
+
+ }
+ >
@@ -320,7 +346,36 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
| {t(`times.entryType.${e.type}`)} |
{formatDateTime(e.startedAt, locale, tz)} |
{formatDateTime(e.endedAt, locale, tz) || "…"} |
- {e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""} |
+
+
+ {e.source === "manual" && {t("times.badgeManual")}}
+ {e.approvalStatus === "pending" && {t("times.badgePending")}}
+ {e.approvalStatus === "rejected" && {t("times.badgeRejected", { reason: e.rejectionReason ?? "" })}}
+ {e.pendingChange !== null && (
+
+ {t("times.badgeCorrection", {
+ from: formatDateTime(new Date(String((e.pendingChange as { startedAt?: string }).startedAt)), locale, tz),
+ to: formatDateTime(new Date(String((e.pendingChange as { endedAt?: string }).endedAt)), locale, tz),
+ })}
+
+ )}
+
+ {e.corrected && {t("times.corrected", { reason: e.correctionReason ?? "" })} }
+ {!e.corrected && e.source === "manual" && e.correctionReason && {t("times.reason", { reason: e.correctionReason })} }
+ {canApprove && e.userId !== ctx.userId && (e.approvalStatus === "pending" || e.pendingChange !== null) && (
+
+ )}
+ |
))}
diff --git a/src/components/work-orders/time-approval-forms.tsx b/src/components/work-orders/time-approval-forms.tsx
new file mode 100644
index 0000000..21bcf22
--- /dev/null
+++ b/src/components/work-orders/time-approval-forms.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useActionState } from "react";
+import { useTranslations } from "next-intl";
+import { AlertCircle, CheckCircle2 } from "lucide-react";
+import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
+import { buttonCls } from "@/components/work-orders/button-cls";
+
+type Action = (prev: ActionState, fd: FormData) => Promise;
+
+/**
+ * L12 bulk approval form with a DOM id, so row checkboxes elsewhere in the table can join it via
+ * the `form` attribute (row approve/reject forms stay separate forms — no nesting).
+ */
+export function BulkApproveForm({ id, action, label, successText }: { id: string; action: Action; label: string; successText: string }) {
+ const [state, formAction, pending] = useActionState(action, IDLE_STATE);
+ const t = useTranslations("workOrders");
+ return (
+
+ );
+}
+
+/** „Alle auswählen" for the checkboxes of one bulk form. */
+export function SelectAllBox({ formId, label }: { formId: string; label: string }) {
+ return (
+ {
+ document.querySelectorAll(`input[type=checkbox][form="${formId}"][name="ids"]`).forEach((box) => {
+ box.checked = e.currentTarget.checked;
+ });
+ }}
+ />
+ );
+}
diff --git a/src/lib/nav.ts b/src/lib/nav.ts
index f2fe292..9f66475 100644
--- a/src/lib/nav.ts
+++ b/src/lib/nav.ts
@@ -14,6 +14,7 @@ import {
ListChecks,
Siren,
Compass,
+ Timer,
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
@@ -49,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
permissions: ["work_order:read_all", "work_order:read_team"],
section: "main",
},
+ { href: "/work-orders/time-approvals", label: "timeApprovals", icon: Timer, module: "work_orders", permissions: ["time:approve"], section: "main" },
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
diff --git a/src/server/actions/field/time.ts b/src/server/actions/field/time.ts
index b5b8750..9bb1227 100644
--- a/src/server/actions/field/time.ts
+++ b/src/server/actions/field/time.ts
@@ -4,10 +4,18 @@ import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { correctTimeEntry } from "@/server/services/field/time-correction";
+import { approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries";
const guard = moduleGuard("field");
-export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed" };
+export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed"; message?: string };
+
+function toResult(err: unknown, label: string): TimeCorrectionResult {
+ if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code, message: err.message };
+ if (err instanceof ServiceError && err.code === "conflict") return { ok: false, error: "invalid", message: err.message };
+ console.error(`[field] ${label} failed:`, err);
+ return { ok: false, error: "failed" };
+}
/** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */
export async function correctTime(input: {
@@ -26,10 +34,32 @@ export async function correctTime(input: {
reason: input.reason,
});
} catch (err) {
- if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code };
- console.error("[field] time correction failed:", err);
- return { ok: false, error: "failed" };
+ return toResult(err, "time correction");
}
revalidatePath(`/m/orders/${input.workOrderId}/time`);
return { ok: true };
}
+
+/** L12: approve a manual entry / correction proposal (team lead mobile) — services/field/time-entries. */
+export async function approveTime(input: { timeEntryId: string }): Promise {
+ const g = await guard("time:approve");
+ try {
+ await approveTimeEntry(ctxFromGuard(g), input.timeEntryId);
+ } catch (err) {
+ return toResult(err, "time approval");
+ }
+ revalidatePath("/m/approvals");
+ return { ok: true };
+}
+
+/** L12: reject with mandatory reason. */
+export async function rejectTime(input: { timeEntryId: string; reason: string }): Promise {
+ const g = await guard("time:approve");
+ try {
+ await rejectTimeEntry(ctxFromGuard(g), input.timeEntryId, input.reason);
+ } catch (err) {
+ return toResult(err, "time rejection");
+ }
+ revalidatePath("/m/approvals");
+ return { ok: true };
+}
diff --git a/src/server/actions/work_orders/time-approvals.ts b/src/server/actions/work_orders/time-approvals.ts
new file mode 100644
index 0000000..6749978
--- /dev/null
+++ b/src/server/actions/work_orders/time-approvals.ts
@@ -0,0 +1,71 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import type { ActionState } from "@/lib/work-orders/action-state";
+import { moduleGuard } from "@/server/action-guard";
+import { ctxFromGuard } from "@/server/services/context";
+import { approveTimeEntries, approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries";
+import { ok, str, toErrorState } from "./_form";
+
+/**
+ * L12 back office: approve / reject manual time entries and correction proposals (`time:approve`).
+ * Thin adapters — permission, team scope, "never own entries", audit and events live in
+ * services/field/time-entries.ts.
+ */
+const guard = moduleGuard("work_orders");
+
+function revalidate(workOrderId?: string) {
+ revalidatePath("/work-orders/time-approvals");
+ if (workOrderId) revalidatePath(`/work-orders/${workOrderId}`);
+}
+
+export async function approveTimeEntryAction(_prev: ActionState, fd: FormData): Promise {
+ const id = str(fd, "timeEntryId") ?? "";
+ let tenantId: string | undefined;
+ let actorId: string | undefined;
+ try {
+ const g = await guard("time:approve");
+ tenantId = g.session.user.tenantId;
+ actorId = g.session.user.id;
+ await approveTimeEntry(ctxFromGuard(g), id);
+ } catch (err) {
+ return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: id });
+ }
+ revalidate(str(fd, "workOrderId"));
+ return ok();
+}
+
+export async function rejectTimeEntryAction(_prev: ActionState, fd: FormData): Promise {
+ const id = str(fd, "timeEntryId") ?? "";
+ let tenantId: string | undefined;
+ let actorId: string | undefined;
+ try {
+ const g = await guard("time:approve");
+ tenantId = g.session.user.tenantId;
+ actorId = g.session.user.id;
+ await rejectTimeEntry(ctxFromGuard(g), id, str(fd, "reason") ?? "");
+ } catch (err) {
+ return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: id });
+ }
+ revalidate(str(fd, "workOrderId"));
+ return ok();
+}
+
+/** Bulk approval of the selected entries (checkboxes `ids`); entries that cannot be approved stay open. */
+export async function approveTimeEntriesAction(_prev: ActionState, fd: FormData): Promise {
+ const ids = fd.getAll("ids").filter((v): v is string => typeof v === "string" && v.length > 0);
+ let tenantId: string | undefined;
+ let actorId: string | undefined;
+ try {
+ const g = await guard("time:approve");
+ tenantId = g.session.user.tenantId;
+ actorId = g.session.user.id;
+ const results = await approveTimeEntries(ctxFromGuard(g), ids);
+ const failed = results.filter((r) => !r.ok).length;
+ revalidate();
+ if (ids.length === 0 || failed === results.length) return { status: "error", code: "invalid", message: "time_bulk_none", at: Date.now() };
+ return ok();
+ } catch (err) {
+ return toErrorState(err, { tenantId, actorId, entity: "time_entry" });
+ }
+}