L12 Zeiterfassung: Mobile Uhr-Leiste, Meine Zeiten, Freigaben und Backoffice-Liste

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 09:29:38 +02:00
co-authored by Claude Opus 5
parent 0e94eb0d9d
commit fe3eba6e44
42 changed files with 2319 additions and 166 deletions
+4 -2
View File
@@ -16,7 +16,8 @@
"billing": "Zur Abrechnung", "billing": "Zur Abrechnung",
"emergency_new": "Neue Notdienste", "emergency_new": "Neue Notdienste",
"missing_signatures": "Fehlende Unterschriften", "missing_signatures": "Fehlende Unterschriften",
"sync_conflicts": "Sync-Konflikte" "sync_conflicts": "Sync-Konflikte",
"time_approvals": "Zeiten zur Freigabe"
}, },
"hints": { "hints": {
"open": "Noch nicht abgerechnet oder storniert", "open": "Noch nicht abgerechnet oder storniert",
@@ -29,7 +30,8 @@
"billing": "Bereit zur Abrechnung", "billing": "Bereit zur Abrechnung",
"emergency_new": "Neu oder zur Prüfung", "emergency_new": "Neu oder zur Prüfung",
"missing_signatures": "Unterschrift ausstehend", "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" "open": "Anzeigen"
} }
+148 -6
View File
@@ -5,7 +5,8 @@
"orders": "Aufträge", "orders": "Aufträge",
"emergency": "Notdienst", "emergency": "Notdienst",
"sync": "Sync", "sync": "Sync",
"profile": "Profil" "profile": "Profil",
"approvalsBadge": "{count} Zeiten zur Freigabe"
}, },
"connection": { "connection": {
"online": "Online", "online": "Online",
@@ -35,7 +36,13 @@
"noDate": "Ohne Termin", "noDate": "Ohne Termin",
"emergency": "Notdienst", "emergency": "Notdienst",
"urgent": "Dringend", "urgent": "Dringend",
"high": "Hoch" "high": "Hoch",
"running": "Zeit läuft",
"paused": "Pausiert",
"enRoute": "In Anfahrt",
"startWork": "Arbeit starten",
"pause": "Pause",
"resume": "Weiter"
}, },
"statusGroup": { "statusGroup": {
"new": "Neu", "new": "Neu",
@@ -78,7 +85,17 @@
"saving": "Wird gespeichert …", "saving": "Wird gespeichert …",
"blocked": "Vor dem Abschluss noch offen:", "blocked": "Vor dem Abschluss noch offen:",
"reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.", "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": { "blocker": {
"checklist_item": "Checkliste: {label}", "checklist_item": "Checkliste: {label}",
@@ -264,7 +281,8 @@
"en_route": "In Anfahrt", "en_route": "In Anfahrt",
"running": "Läuft", "running": "Läuft",
"paused": "Pausiert", "paused": "Pausiert",
"ended": "Beendet" "ended": "Beendet",
"manual": "Nachgetragen"
}, },
"running": "läuft", "running": "läuft",
"offlineStarted": "offline gestartet", "offlineStarted": "offline gestartet",
@@ -277,7 +295,10 @@
"save": "Korrektur speichern", "save": "Korrektur speichern",
"saved": "Korrektur gespeichert.", "saved": "Korrektur gespeichert.",
"empty": "Noch keine Zeiten erfasst.", "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": { "profile": {
"title": "Profil", "title": "Profil",
@@ -287,12 +308,133 @@
"roles": "Rolle", "roles": "Rolle",
"language": "Sprache", "language": "Sprache",
"logout": "Abmelden", "logout": "Abmelden",
"backoffice": "Zum Büro-Bereich" "backoffice": "Zum Büro-Bereich",
"myTime": "Meine Zeiten",
"approvals": "Zeiten freigeben"
}, },
"sync": { "sync": {
"title": "Synchronisation", "title": "Synchronisation",
"immediate": "Änderungen werden sofort an das Büro übertragen.", "immediate": "Änderungen werden sofort an das Büro übertragen.",
"offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.", "offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.",
"status": "Verbindung" "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."
} }
} }
+2 -1
View File
@@ -16,5 +16,6 @@
"lotse": "Lotse (KI)", "lotse": "Lotse (KI)",
"admin": "Admin-Konsole", "admin": "Admin-Konsole",
"openMenu": "Menü öffnen", "openMenu": "Menü öffnen",
"closeMenu": "Menü schließen" "closeMenu": "Menü schließen",
"timeApprovals": "Zeiten zur Freigabe"
} }
+84 -18
View File
@@ -45,26 +45,92 @@
"emergency_completed": "Notdiensteinsatz abgeschlossen", "emergency_completed": "Notdiensteinsatz abgeschlossen",
"import_ready_for_review": "Import zur Prüfung", "import_ready_for_review": "Import zur Prüfung",
"import_failed": "Fehler bei Dokumentverarbeitung", "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": { "events": {
"work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, "work_order_assigned": {
"work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." }, "title": "Neuer Auftrag {number}",
"work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." }, "message": "{title} – {customer} wurde Ihrem Team zugewiesen."
"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_changed": {
"work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." }, "title": "Auftrag {number} geändert",
"work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." }, "message": "{title} – {customer}: Angaben wurden aktualisiert."
"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." }, "work_order_cancelled": {
"report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." }, "title": "Auftrag {number} storniert",
"report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." }, "message": "{title} – {customer} wurde storniert."
"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." }, "work_order_started": {
"emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, "title": "Auftrag {number} gestartet",
"import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, "message": "{actor} hat den Einsatz bei {customer} begonnen."
"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_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": { "fallback": {
"unknown": "unbekannt", "unknown": "unbekannt",
+4 -1
View File
@@ -89,7 +89,10 @@
"upload": "Die Datei wurde nicht angenommen (Format oder Größe).", "upload": "Die Datei wurde nicht angenommen (Format oder Größe).",
"unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.", "unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.",
"network": "Keine Verbindung – wird automatisch erneut versucht.", "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": { "view": {
"title": "Offline-Ansicht", "title": "Offline-Ansicht",
+44 -3
View File
@@ -237,7 +237,21 @@
"material_procurement": "Materialbeschaffung", "material_procurement": "Materialbeschaffung",
"return_travel": "Rückfahrt", "return_travel": "Rückfahrt",
"interruption": "Unterbrechung" "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": { "photos": {
"empty": "Noch keine Fotos.", "empty": "Noch keine Fotos.",
@@ -460,8 +474,35 @@
"running_session": "Ein Einsatz läuft noch", "running_session": "Ein Einsatz läuft noch",
"field": { "field": {
"signature": "Unterschrift fehlt", "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}}"
}
} }
+4 -2
View File
@@ -16,7 +16,8 @@
"billing": "Ready for billing", "billing": "Ready for billing",
"emergency_new": "New emergencies", "emergency_new": "New emergencies",
"missing_signatures": "Missing signatures", "missing_signatures": "Missing signatures",
"sync_conflicts": "Sync conflicts" "sync_conflicts": "Sync conflicts",
"time_approvals": "Time approvals"
}, },
"hints": { "hints": {
"open": "Not yet billed or cancelled", "open": "Not yet billed or cancelled",
@@ -29,7 +30,8 @@
"billing": "Ready to invoice", "billing": "Ready to invoice",
"emergency_new": "New or in review", "emergency_new": "New or in review",
"missing_signatures": "Signature pending", "missing_signatures": "Signature pending",
"sync_conflicts": "Review offline changes" "sync_conflicts": "Review offline changes",
"time_approvals": "Review added time and corrections"
}, },
"open": "Show" "open": "Show"
} }
+148 -6
View File
@@ -5,7 +5,8 @@
"orders": "Orders", "orders": "Orders",
"emergency": "Emergency", "emergency": "Emergency",
"sync": "Sync", "sync": "Sync",
"profile": "Profile" "profile": "Profile",
"approvalsBadge": "{count} time entries to approve"
}, },
"connection": { "connection": {
"online": "Online", "online": "Online",
@@ -35,7 +36,13 @@
"noDate": "No date", "noDate": "No date",
"emergency": "Emergency", "emergency": "Emergency",
"urgent": "Urgent", "urgent": "Urgent",
"high": "High" "high": "High",
"running": "Time running",
"paused": "Paused",
"enRoute": "On the way",
"startWork": "Start work",
"pause": "Pause",
"resume": "Resume"
}, },
"statusGroup": { "statusGroup": {
"new": "New", "new": "New",
@@ -78,7 +85,17 @@
"saving": "Saving …", "saving": "Saving …",
"blocked": "Still missing before completion:", "blocked": "Still missing before completion:",
"reportNext": "Report and signature follow in the next step.", "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": { "blocker": {
"checklist_item": "Checklist: {label}", "checklist_item": "Checklist: {label}",
@@ -264,7 +281,8 @@
"en_route": "On the way", "en_route": "On the way",
"running": "Running", "running": "Running",
"paused": "Paused", "paused": "Paused",
"ended": "Ended" "ended": "Ended",
"manual": "Recorded later"
}, },
"running": "running", "running": "running",
"offlineStarted": "started offline", "offlineStarted": "started offline",
@@ -277,7 +295,10 @@
"save": "Save correction", "save": "Save correction",
"saved": "Correction saved.", "saved": "Correction saved.",
"empty": "No time recorded yet.", "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": { "profile": {
"title": "Profile", "title": "Profile",
@@ -287,12 +308,133 @@
"roles": "Role", "roles": "Role",
"language": "Language", "language": "Language",
"logout": "Sign out", "logout": "Sign out",
"backoffice": "Go to office area" "backoffice": "Go to office area",
"myTime": "My time",
"approvals": "Approve time"
}, },
"sync": { "sync": {
"title": "Synchronisation", "title": "Synchronisation",
"immediate": "Changes are sent to the office immediately.", "immediate": "Changes are sent to the office immediately.",
"offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.", "offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.",
"status": "Connection" "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."
} }
} }
+2 -1
View File
@@ -16,5 +16,6 @@
"lotse": "Lotse (AI)", "lotse": "Lotse (AI)",
"admin": "Admin console", "admin": "Admin console",
"openMenu": "Open menu", "openMenu": "Open menu",
"closeMenu": "Close menu" "closeMenu": "Close menu",
"timeApprovals": "Time approvals"
} }
+84 -18
View File
@@ -45,26 +45,92 @@
"emergency_completed": "Emergency call-out completed", "emergency_completed": "Emergency call-out completed",
"import_ready_for_review": "Import ready for review", "import_ready_for_review": "Import ready for review",
"import_failed": "Document processing failed", "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": { "events": {
"work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, "work_order_assigned": {
"work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." }, "title": "New work order {number}",
"work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." }, "message": "{title} – {customer} was assigned to your team."
"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_changed": {
"work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." }, "title": "Work order {number} changed",
"work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." }, "message": "{title} – {customer}: details were updated."
"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." }, "work_order_cancelled": {
"report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." }, "title": "Work order {number} cancelled",
"report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." }, "message": "{title} – {customer} was cancelled."
"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}." }, "work_order_started": {
"emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, "title": "Work order {number} started",
"import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, "message": "{actor} started work at {customer}."
"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_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": { "fallback": {
"unknown": "unknown", "unknown": "unknown",
+4 -1
View File
@@ -89,7 +89,10 @@
"upload": "The file was not accepted (format or size).", "upload": "The file was not accepted (format or size).",
"unauthorized": "Your session expired – please sign in again.", "unauthorized": "Your session expired – please sign in again.",
"network": "No connection – will retry automatically.", "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": { "view": {
"title": "Offline view", "title": "Offline view",
+44 -3
View File
@@ -237,7 +237,21 @@
"material_procurement": "Material procurement", "material_procurement": "Material procurement",
"return_travel": "Return travel", "return_travel": "Return travel",
"interruption": "Interruption" "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": { "photos": {
"empty": "No photos yet.", "empty": "No photos yet.",
@@ -460,8 +474,35 @@
"running_session": "A work session is still running", "running_session": "A work session is still running",
"field": { "field": {
"signature": "Signature missing", "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}}"
}
} }
+6 -4
View File
@@ -12,6 +12,7 @@ import {
Receipt, Receipt,
RefreshCw, RefreshCw,
Siren, Siren,
Timer,
Wrench, Wrench,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>; type SP = Record<string, string | string[] | undefined>;
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: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
{ key: "today", icon: CalendarDays, tone: "var(--info)" }, { key: "today", icon: CalendarDays, tone: "var(--info)" },
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" }, { 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: "emergency_new", icon: Siren, tone: "var(--risk)" },
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" }, { key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
{ key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" }, { 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. */ /** 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
</details> </details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { {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 === "reports_in_review" ? tiles.reportsToReview : tiles[key]; 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 === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: 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 ( return (
<li key={key}> <li key={key}>
<Link <Link
@@ -0,0 +1,116 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { ActionForm } from "@/components/work-orders/action-form";
import { pageContext } from "@/components/work-orders/page-context";
import { BulkApproveForm, SelectAllBox } from "@/components/work-orders/time-approval-forms";
import { Empty, inputCls } from "@/components/work-orders/ui";
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
import { approveTimeEntriesAction, approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
import { listPendingTimeEntries } from "@/server/services/field/time-entries";
const BULK_FORM = "time-approvals-bulk";
/** L12 `/work-orders/time-approvals` — open manual entries and correction proposals (`time:approve`), single and bulk approval. */
export default async function TimeApprovalsPage() {
const { ctx, locale, tz, can } = await pageContext();
if (!can("time:approve")) redirect("/work-orders");
const t = await getTranslations("workOrders");
const tf = await getTranslations("field");
const items = await listPendingTimeEntries(ctx);
const time = (d: Date) => 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 (
<main className="flex-1 p-4 md:p-6">
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden />
{t("detail.back")}
</Link>
<PageHead crumb={t("timeApprovals.crumb")} title={t("timeApprovals.title")} sub={t("timeApprovals.sub")} />
{items.length === 0 ? (
<Empty>{t("timeApprovals.empty")}</Empty>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold">{t("timeApprovals.count", { count: items.length })}</p>
<BulkApproveForm id={BULK_FORM} action={approveTimeEntriesAction} label={t("timeApprovals.approveSelected")} successText={t("timeApprovals.approved")} />
</div>
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[980px] text-sm">
<thead className="border-b text-left text-xs text-muted-foreground">
<tr>
<th className="p-3">
<SelectAllBox formId={BULK_FORM} label={t("timeApprovals.selectAll")} />
</th>
<th className="p-3">{t("timeApprovals.employee")}</th>
<th className="p-3">{t("timeApprovals.order")}</th>
<th className="p-3">{t("timeApprovals.date")}</th>
<th className="p-3">{t("timeApprovals.range")}</th>
<th className="p-3">{t("timeApprovals.duration")}</th>
<th className="p-3">{t("timeApprovals.type")}</th>
<th className="p-3">{t("timeApprovals.reason")}</th>
<th className="p-3" />
</tr>
</thead>
<tbody>
{items.map((i) => (
<tr key={i.id} className="border-b align-top last:border-0">
<td className="p-3">
<input type="checkbox" name="ids" value={i.id} form={BULK_FORM} aria-label={`${t("timeApprovals.select")}: ${i.userName} ${i.number}`} className="size-5" />
</td>
<td className="p-3">
<p className="font-semibold">{i.userName}</p>
<p className="text-xs text-muted-foreground">{i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}</p>
</td>
<td className="p-3">
<Link href={`/work-orders/${i.workOrderId}?tab=times`} className="font-semibold text-[var(--primary)] hover:underline">
{i.number}
</Link>
<p className="text-xs text-muted-foreground">{i.title}</p>
</td>
<td className="p-3 whitespace-nowrap">{formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)}</td>
<td className="p-3 whitespace-nowrap">
{i.proposed ? (
t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) })
) : (
span(i.startedAt, i.endedAt)
)}
</td>
<td className="p-3 whitespace-nowrap">
{i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`}
</td>
<td className="p-3">
{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}`)}
</td>
<td className="p-3">
<p>{i.reason}</p>
{i.note && <p className="text-xs text-muted-foreground">{i.note}</p>}
<p className="text-xs text-muted-foreground">{formatDateTime(i.requestedAt, locale, tz)}</p>
</td>
<td className="space-y-2 p-3">
<ActionForm action={approveTimeEntryAction} submitLabel={t("timeApprovals.approve")} variant="primary" successText={t("timeApprovals.approved")} footerClassName="mt-0">
<input type="hidden" name="timeEntryId" value={i.id} />
<input type="hidden" name="workOrderId" value={i.workOrderId} />
</ActionForm>
<ActionForm action={rejectTimeEntryAction} submitLabel={t("timeApprovals.reject")} variant="outline" successText={t("timeApprovals.rejected")} footerClassName="mt-2">
<input type="hidden" name="timeEntryId" value={i.id} />
<input type="hidden" name="workOrderId" value={i.workOrderId} />
<input name="reason" required minLength={3} maxLength={500} placeholder={t("timeApprovals.rejectReason")} aria-label={t("timeApprovals.rejectReason")} className={inputCls} />
</ActionForm>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
);
}
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
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 (
<main className="space-y-4 p-4">
<div>
<h1 className="text-[26px]">{t("approvals.title")}</h1>
<p className="text-[14px] text-muted-foreground">{t("approvals.sub")}</p>
<p className="text-[15px] font-semibold">{t("approvals.count", { count: items.length })}</p>
</div>
{items.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("approvals.empty")}</p>
) : (
<ul className="space-y-3">
{items.map((i) => (
<li key={i.id} className={`${card} border-l-4 border-l-[var(--warn)]`}>
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">{i.kind === "correction" ? t("approvals.correction") : t("approvals.manual")}</p>
<p className="text-[18px] font-semibold">{i.userName}</p>
<Link href={`/m/orders/${i.workOrderId}`} className="block text-[15px] text-primary underline-offset-4 hover:underline">
{i.number} · {i.title}
</Link>
<dl className="mt-2 space-y-1 text-[15px]">
<div>
<dt className="sr-only">{i.kind === "correction" ? t("approvals.old") : t("myTime.form.type")}</dt>
<dd className={i.proposed ? "text-muted-foreground line-through" : "font-semibold"}>
{i.proposed && <span className="no-underline">{t("approvals.old")}: </span>}
{t(`time.type.${i.type}`)} · {range(i.startedAt, i.endedAt)} · {fmtDuration(i.minutes * 60)}
</dd>
</div>
{i.proposed && (
<div>
<dt className="sr-only">{t("approvals.new")}</dt>
<dd className="font-semibold">
{t("approvals.new")}: {t(`time.type.${i.proposed.type}`)} · {range(i.proposed.startedAt, i.proposed.endedAt)} · {fmtDuration(i.proposed.minutes * 60)}
</dd>
</div>
)}
{i.reason && (
<div>
<dt className="inline text-muted-foreground">{t("approvals.reason")}: </dt>
<dd className="inline">{i.reason}</dd>
</div>
)}
{i.note && (
<div>
<dt className="inline text-muted-foreground">{t("approvals.note")}: </dt>
<dd className="inline">{i.note}</dd>
</div>
)}
</dl>
<div className="mt-3">
<ApprovalActions timeEntryId={i.id} />
</div>
</li>
))}
</ul>
)}
</main>
);
}
@@ -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 requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length;
const checklistDone = order.checklistItems.filter((i) => i.checked).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; const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length;
// L12: only approved entries count
const myWorkSeconds = order.workSessions const myWorkSeconds = order.workSessions
.filter((s) => s.userId === ctx.userId) .filter((s) => s.userId === ctx.userId)
.flatMap((s) => s.entries) .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); .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
const editable = can(ctx, "field:execute"); 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 = [ const quick = [
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote }, { href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
@@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
</p> </p>
{editable && ( {editable && (
<div className="mt-4"> <div className="mt-4">
<PrimaryAction workOrderId={order.id} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} /> <PrimaryAction workOrderId={order.id} number={order.number} segmentType={segmentType} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} />
</div> </div>
)} )}
</section> </section>
@@ -1,7 +1,13 @@
import { ReportScreen } from "@/components/reports/mobile/report-screen"; 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 }> }) { export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]); const [{ id }, sp] = await Promise.all([params, searchParams]);
return <ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />; return (
<>
<PendingTimeNotice workOrderId={id} />
<ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />
</>
);
} }
@@ -1,40 +1,59 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { can } from "@/server/services/context"; import { can } from "@/server/services/context";
import { parsePendingChange } from "@/server/services/field/time-entries";
import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format"; import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format";
import { SubPageHeader } from "@/components/field/sub-page-header"; import { SubPageHeader } from "@/components/field/sub-page-header";
import { TimeCorrectionForm } from "@/components/field/time-correction-form"; 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"; 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 }> }) { export default async function TimePage({ params }: { params: Promise<{ id: string }> }) {
const { ctx, order } = await loadOrder(params); const { ctx, order } = await loadOrder(params);
const t = await getTranslations("field"); const t = await getTranslations("field");
const locale = await getLocale(); const locale = await getLocale();
const canCorrect = can(ctx, "field:correct_time"); const canCorrect = can(ctx, "field:correct_time");
const totalWork = order.workSessions const canPropose = can(ctx, "field:record_own_time") || canCorrect;
.flatMap((s) => s.entries) const entries = order.workSessions.flatMap((s) => s.entries);
.filter((e) => e.type === "work") const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
.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 ( return (
<main className="space-y-4 pb-4"> <main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} /> <SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} />
<div className="space-y-3 px-4"> <div className="space-y-3 px-4">
<p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p> <p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p>
{pendingWork > 0 && <p className="text-[15px] font-semibold text-[var(--warn)]">{t("time.totalPending", { duration: fmtDuration(pendingWork) })}</p>}
{canPropose && (
<Link href={`/m/time/new?order=${order.id}`} className={btnSecondary}>
<Plus className="size-5" aria-hidden />
{t("myTime.add")}
</Link>
)}
{order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>} {order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>}
{order.workSessions.map((s) => ( {order.workSessions.map((s) => (
<section key={s.id} className={card}> <section key={s.id} className={card}>
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-[17px]">{s.user.name}</h2> <h2 className="text-[17px]">{s.user.name}</h2>
<span className="text-[13px] font-semibold"> <span className="text-[13px] font-semibold">
{t(`time.session.${s.status}`)} {s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)}
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""} {s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
</span> </span>
</div> </div>
<p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p> <p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p>
<ul className="mt-2 divide-y"> <ul className="mt-2 divide-y">
{s.entries.map((e) => ( {s.entries.map((e) => {
const own = e.userId === ctx.userId;
const pendingChange = parsePendingChange(e.pendingChange);
return (
<li key={e.id} className="space-y-2 py-2.5"> <li key={e.id} className="space-y-2 py-2.5">
<div className="flex items-baseline justify-between gap-2 text-[15px]"> <div className="flex items-baseline justify-between gap-2 text-[15px]">
<span className="font-semibold">{t(`time.type.${e.type}`)}</span> <span className="font-semibold">{t(`time.type.${e.type}`)}</span>
@@ -42,10 +61,15 @@ export default async function TimePage({ params }: { params: Promise<{ id: strin
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
</span> </span>
</div> </div>
<TimeEntryBadges entry={{ source: e.source, approvalStatus: e.approvalStatus, rejectionReason: e.rejectionReason, corrected: e.corrected, pendingChange }} />
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>} {e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
{canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />} {own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && (
<CorrectionProposalForm workOrderId={order.id} entry={{ id: e.id, type: e.type, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt.toISOString() }} />
)}
{!own && canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
</li> </li>
))} );
})}
</ul> </ul>
</section> </section>
))} ))}
+3 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries"; import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card"; 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: rawTab } = await searchParams;
const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running"; const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running";
const orders = await listFieldOrders(ctx, tab); const orders = await listFieldOrders(ctx, tab);
const canExecute = can(ctx, "field:execute");
return ( return (
<main className="space-y-4 p-4"> <main className="space-y-4 p-4">
@@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
<ul className="space-y-3"> <ul className="space-y-3">
{orders.map((o) => ( {orders.map((o) => (
<li key={o.id}> <li key={o.id}>
<OrderCard order={o} /> <OrderCard order={o} canExecute={canExecute} />
</li> </li>
))} ))}
</ul> </ul>
+3 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listTodayOrders } from "@/server/services/field/queries"; import { listTodayOrders } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card"; import { OrderCard } from "@/components/field/order-card";
@@ -12,6 +13,7 @@ export default async function TodayPage() {
const t = await getTranslations("field"); const t = await getTranslations("field");
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>; if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
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 } })]); const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
return ( return (
@@ -27,7 +29,7 @@ export default async function TodayPage() {
<ul className="space-y-3"> <ul className="space-y-3">
{orders.map((o) => ( {orders.map((o) => (
<li key={o.id}> <li key={o.id}>
<OrderCard order={o} /> <OrderCard order={o} canExecute={canExecute} />
</li> </li>
))} ))}
</ul> </ul>
+13 -1
View File
@@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth";
import { prisma } from "@/server/db"; import { prisma } from "@/server/db";
import { can } from "@/server/services/context"; import { can } from "@/server/services/context";
import { activeTeamIds } from "@/server/services/work-orders/visibility"; 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 { fieldPageContext } from "@/server/services/field/page-context";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { btnSecondary, card } from "@/components/field/ui"; import { btnSecondary, card } from "@/components/field/ui";
@@ -15,7 +16,7 @@ export default async function ProfilePage() {
const ctx = await fieldPageContext(); const ctx = await fieldPageContext();
const t = await getTranslations("field.profile"); const t = await getTranslations("field.profile");
const session = await auth(); 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([ const [me, teams, identity] = await Promise.all([
ctx.db.user.findFirst({ ctx.db.user.findFirst({
where: { id: ctx.userId }, where: { id: ctx.userId },
@@ -58,6 +59,17 @@ export default async function ProfilePage() {
</div> </div>
</dl> </dl>
</section> </section>
{can(ctx, "field:execute") && (
<Link href="/m/time" className={btnSecondary}>
{t("myTime")}
</Link>
)}
{can(ctx, "time:approve") && (
<Link href="/m/approvals" className={btnSecondary}>
{t("approvals")}
{approvals > 0 && <span className="rounded-full bg-cta px-2 text-[13px] font-bold text-cta-foreground">{approvals}</span>}
</Link>
)}
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && ( {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
<Link href="/dashboard" className={btnSecondary}> <Link href="/dashboard" className={btnSecondary}>
{t("backoffice")} {t("backoffice")}
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const sp = await searchParams;
const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]);
return (
<main className="space-y-4 p-4">
<Link href={`/m/time?date=${overview.date}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("myTime.title")}
</Link>
<h1 className="text-[26px]">{t("myTime.form.title")}</h1>
<ManualTimeForm
orders={orders}
users={users}
days={overview.days}
defaultDate={overview.date}
defaultWorkOrderId={orders.some((o) => o.id === sp.order) ? sp.order! : null}
/>
</main>
);
}
+105
View File
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
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 (
<main className="space-y-4 p-4">
<div>
<h1 className="text-[26px]">{t("myTime.title")}</h1>
<p className="text-[14px] text-muted-foreground">{t("myTime.sub")}</p>
</div>
<nav aria-label={t("myTime.days")} className="-mx-4 overflow-x-auto px-4">
<ul className="flex gap-2">
{overview.days.map((key, i) => (
<li key={key} className="shrink-0">
<Link href={`/m/time?date=${key}`} aria-current={overview.date === key ? "page" : undefined} className={cn(chip(overview.date === key), "px-4")}>
{dayLabel(key, i)}
</Link>
</li>
))}
</ul>
</nav>
<section className={cn(card, "grid grid-cols-2 gap-3")} aria-label={t("myTime.dayTotal")}>
<div>
<p className="text-[13px] font-semibold text-muted-foreground">{t("myTime.approved")}</p>
<p className="font-heading text-[22px] font-bold tabular-nums">{fmtDuration(overview.approvedSeconds)}</p>
</div>
<div>
<p className="text-[13px] font-semibold text-muted-foreground">{t("myTime.pending")}</p>
<p className={cn("font-heading text-[22px] font-bold tabular-nums", overview.pendingSeconds > 0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}</p>
</div>
</section>
{canRecord && (
<Link href={`/m/time/new?date=${overview.date}`} className={btnPrimary}>
<Plus className="size-5" aria-hidden />
{t("myTime.add")}
</Link>
)}
<LocalPendingTimes />
{overview.groups.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("myTime.empty")}</p>
) : (
overview.groups.map((g) => (
<section key={g.workOrderId} className={card}>
<Link href={`/m/orders/${g.workOrderId}`} className="flex min-h-12 items-center gap-2">
<span className="flex-1">
<span className="block font-mono text-[13px] font-semibold text-muted-foreground">{g.number}</span>
<span className="block text-[17px] font-semibold leading-snug">{g.title}</span>
</span>
<ChevronRight className="size-5 text-muted-foreground" aria-hidden />
</Link>
<p className="text-[14px]">
{t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })}
{g.pendingSeconds > 0 && <span className="font-semibold text-[var(--warn)]"> · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}</span>}
</p>
<ul className="mt-2 divide-y">
{g.entries.map((e) => (
<li key={e.id} className="space-y-2 py-2.5">
<div className="flex items-baseline justify-between gap-2 text-[15px]">
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
<span className="tabular-nums">
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("myTime.running")} · {fmtDuration(e.seconds)}
</span>
</div>
<TimeEntryBadges entry={e} />
{canRecord && e.editable && (
<CorrectionProposalForm
workOrderId={g.workOrderId}
entry={{ id: e.id, type: e.type, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }}
/>
)}
</li>
))}
</ul>
</section>
))
)}
</main>
);
}
+28 -3
View File
@@ -1,21 +1,45 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { requireAppAccess } from "@/server/app-access"; 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 { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BottomNav } from "@/components/field/bottom-nav"; import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge"; import { OnlineBadge } from "@/components/field/online-badge";
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime"; 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 * 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. * (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge.
* Module gates live one level below: (core) → "field", emergency → "emergency". * 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 }>) { export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) {
const access = await requireAppAccess(); const access = await requireAppAccess();
if (access.kind === "inactive") return <AccountInactiveNotice />; if (access.kind === "inactive") return <AccountInactiveNotice />;
const t = await getTranslations("field.nav"); const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]);
return ( return (
<div className="flex min-h-screen flex-1 flex-col bg-background"> <div className="flex min-h-screen flex-1 flex-col bg-background">
@@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<OnlineBadge /> <OnlineBadge />
</div> </div>
</header> </header>
<div className="mx-auto w-full max-w-xl flex-1 pb-28">{children}</div> <div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<BottomNav /> <RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} />
</div> </div>
); );
} }
+73
View File
@@ -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<string | null>(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 (
<div className="space-y-2.5">
{!rejecting ? (
<div className="grid grid-cols-2 gap-2">
<button type="button" className={btnPrimary} disabled={busy} onClick={() => run("approve")}>
<Check className="size-5" aria-hidden />
{t("approvals.approve")}
</button>
<button type="button" className={cn(btnSecondary, "min-h-14")} disabled={busy} onClick={() => setRejecting(true)}>
<X className="size-5" aria-hidden />
{t("approvals.reject")}
</button>
</div>
) : (
<div className="space-y-2.5 rounded-xl bg-muted p-3">
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("approvals.rejectReason")}</span>
<textarea rows={2} required minLength={3} maxLength={500} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<button type="button" className={btnPrimary} disabled={busy} onClick={() => run("reject")}>
{busy ? t("action.saving") : t("approvals.rejectConfirm")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setRejecting(false)}>
{t("approvals.cancel")}
</button>
</div>
)}
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
</div>
);
}
+13 -3
View File
@@ -14,8 +14,11 @@ const ITEMS = [
{ href: "/m/profile", key: "profile", icon: User, exact: false }, { href: "/m/profile", key: "profile", icon: User, exact: false },
] as const; ] 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 t = useTranslations("field.nav");
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@@ -23,6 +26,7 @@ export function BottomNav() {
<ul className="mx-auto grid max-w-xl grid-cols-5"> <ul className="mx-auto grid max-w-xl grid-cols-5">
{ITEMS.map((item) => { {ITEMS.map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`); const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
@@ -33,10 +37,16 @@ export function BottomNav() {
active ? "text-primary" : "text-muted-foreground hover:text-foreground", active ? "text-primary" : "text-muted-foreground hover:text-foreground",
)} )}
> >
<span className={cn("grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}> <span className={cn("relative grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}>
<item.icon className="size-5.5" aria-hidden /> <item.icon className="size-5.5" aria-hidden />
{badge > 0 && (
<span className="absolute -top-1 -right-0.5 grid min-w-5 place-items-center rounded-full bg-cta px-1 text-[11px] leading-5 font-bold text-cta-foreground" aria-hidden>
{badge > 99 ? "99+" : badge}
</span>
)}
</span> </span>
{t(item.key)} {t(item.key)}
{badge > 0 && <span className="sr-only">{t("approvalsBadge", { count: badge })}</span>}
</Link> </Link>
</li> </li>
); );
+56
View File
@@ -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<string | null>(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 (
<>
<button type="button" className={btnSecondary} onClick={run} disabled={loading}>
<Icon className={loading ? "size-5 animate-spin" : "size-5"} aria-hidden />
{action === "pause" ? t("card.pause") : action === "resume" ? t("card.resume") : t("card.startWork")}
</button>
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
{sheet}
</>
);
}
@@ -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 <input type="datetime-local"> 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<string | null>(null);
const [done, setDone] = useState<string | null>(null);
if (!open) {
return (
<>
<button
type="button"
className={cn(btnSecondary, "min-h-12")}
onClick={() => {
setStart(toLocalInput(entry.startedAt));
setEnd(entry.endedAt ? toLocalInput(entry.endedAt) : "");
setType(entry.type);
setDone(null);
setOpen(true);
}}
>
<PenLine className="size-4.5" aria-hidden />
{t("time.propose")}
</button>
{done && (
<p className={noticeOk} role="status">
{done}
</p>
)}
</>
);
}
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 (
<form onSubmit={submit} className="space-y-3 rounded-xl bg-muted p-3">
<p className="text-[15px] font-semibold">{t("myTime.correction.title")}</p>
<p className="text-[13px] text-muted-foreground">{t("myTime.correction.hint")}</p>
<fieldset>
<legend className="mb-1.5 text-[14px] font-semibold">{t("myTime.form.type")}</legend>
<div className="flex flex-wrap gap-2">
{types.map((ty) => (
<button key={ty} type="button" aria-pressed={type === ty} className={chip(type === ty)} onClick={() => setType(ty)}>
{t(`time.type.${ty}`)}
</button>
))}
</div>
</fieldset>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.start")}</span>
<input type="datetime-local" required className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.end")}</span>
<input type="datetime-local" required className={inputClass} value={end} min={start} onChange={(e) => setEnd(e.target.value)} />
</label>
<ReasonField value={reason} onChange={setReason} />
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
<button type="submit" className={btnPrimary} disabled={busy}>
{busy ? t("action.saving") : t("myTime.correction.save")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setOpen(false)}>
{t("myTime.form.cancel")}
</button>
</form>
);
}
/** 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 (
<div>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("form.reason")}</span>
<textarea rows={2} required minLength={3} maxLength={500} className={cn(inputClass, "py-3")} value={value} onChange={(e) => onChange(e.target.value)} />
</label>
<div className="mt-2 flex flex-wrap gap-2">
{REASON_SUGGESTIONS.map((key) => {
const text = t(`reasons.${key}`);
return (
<button key={key} type="button" aria-pressed={value === text} className={chip(value === text)} onClick={() => onChange(text)}>
{text}
</button>
);
})}
</div>
<span className="mt-1 block text-[13px] text-muted-foreground">{t("form.reasonHint")}</span>
</div>
);
}
@@ -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<OutboxEntry[]>([]);
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 (
<section className={card} aria-label={t("myTime.localTitle")}>
<h2 className="flex items-center gap-2 text-[17px]">
<CloudOff className="size-5 text-muted-foreground" aria-hidden />
{t("myTime.localTitle")}
</h2>
<ul className="mt-2 divide-y">
{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 (
<li key={o.clientOpId} className="flex flex-wrap items-baseline justify-between gap-2 py-2.5 text-[15px]">
<span className="font-semibold">{label}</span>
<span className="text-[14px]">
{fmtDateTime(when, locale)}
{p.durationMinutes ? ` · ${p.durationMinutes} min` : ""}
</span>
<span className="inline-flex min-h-7 items-center rounded-lg bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] px-2 text-[13px] font-semibold">{t("myTime.localPending")}</span>
</li>
);
})}
</ul>
</section>
);
}
+207
View File
@@ -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<string | null>(null);
const [done, setDone] = useState<string | null>(null);
if (orders.length === 0) return <p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("myTime.form.noOrders")}</p>;
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 (
<form onSubmit={submit} className="space-y-4">
<label className="block">
<span className={label}>{t("myTime.form.order")}</span>
<select required className={inputClass} value={workOrderId} onChange={(e) => setWorkOrderId(e.target.value)}>
<option value="">{t("myTime.form.orderPlaceholder")}</option>
{orders.map((o) => (
<option key={o.id} value={o.id}>
{o.number} · {o.title}
</option>
))}
</select>
</label>
{users.length > 0 && (
<label className="block">
<span className={label}>{t("myTime.form.forUser")}</span>
<select className={inputClass} value={forUserId} onChange={(e) => setForUserId(e.target.value)}>
<option value="">{t("myTime.form.me")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
)}
<fieldset>
<legend className={label}>{t("myTime.form.type")}</legend>
<div className="flex flex-wrap gap-2">
{MANUAL_TIME_TYPES.map((ty) => (
<button key={ty} type="button" aria-pressed={type === ty} className={chip(type === ty)} onClick={() => setType(ty)}>
{t(`time.type.${ty}`)}
</button>
))}
</div>
</fieldset>
<label className="block">
<span className={label}>{t("myTime.form.date")}</span>
<select className={inputClass} value={date} onChange={(e) => setDate(e.target.value)}>
{days.map((d, i) => (
<option key={d} value={d}>
{i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${d}T12:00:00Z`, locale)}
</option>
))}
</select>
</label>
<fieldset>
<legend className={label}>{t("myTime.form.mode")}</legend>
<div className="grid grid-cols-2 gap-2">
<button type="button" aria-pressed={mode === "range"} className={chip(mode === "range")} onClick={() => setMode("range")}>
{t("myTime.form.modeRange")}
</button>
<button type="button" aria-pressed={mode === "duration"} className={chip(mode === "duration")} onClick={() => setMode("duration")}>
{t("myTime.form.modeDuration")}
</button>
</div>
</fieldset>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className={label}>{t("myTime.form.from")}</span>
<input type="time" required className={inputClass} value={from} onChange={(e) => setFrom(e.target.value)} />
</label>
{mode === "range" ? (
<label className="block">
<span className={label}>{t("myTime.form.to")}</span>
<input type="time" required className={inputClass} value={to} onChange={(e) => setTo(e.target.value)} />
</label>
) : (
<label className="block">
<span className={label}>{t("myTime.form.duration")}</span>
<input type="number" inputMode="numeric" min={1} max={MAX_ENTRY_MINUTES} required className={inputClass} value={duration} onChange={(e) => setDuration(Number(e.target.value))} />
</label>
)}
</div>
{mode === "duration" && (
<div className="flex flex-wrap gap-2">
{DURATION_QUICK_PICKS.map((m) => (
<button key={m} type="button" aria-pressed={duration === m} className={cn(chip(duration === m), "flex-1")} onClick={() => setDuration(m)}>
{t("myTime.form.quick", { minutes: m })}
</button>
))}
</div>
)}
<ReasonField value={reason} onChange={setReason} />
<label className="block">
<span className={label}>{t("myTime.form.note")}</span>
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
<p className={forUserId ? noticeOk : noticeWarn}>
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{forUserId ? t("myTime.form.directHint") : t("myTime.form.approvalHint")}
</p>
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
{done && (
<p className={noticeOk} role="status">
{done}
</p>
)}
<button type="submit" className={btnPrimary} disabled={busy}>
{busy ? t("action.saving") : t("myTime.form.save")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => router.push(`/m/time?date=${date}`)}>
{t("myTime.form.cancel")}
</button>
</form>
);
}
+22 -5
View File
@@ -1,23 +1,37 @@
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; 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 { cn } from "@/lib/utils";
import { fmtWindow } from "@/lib/field/format"; import { fmtWindow } from "@/lib/field/format";
import type { OrderCard as OrderCardData } from "@/server/services/field/queries"; import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
import { StatusBadge } from "./status-badge"; 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. */ /** 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 t = useTranslations("field.card");
const locale = useLocale(); const locale = useLocale();
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale); const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
const clock = order.mySession;
return ( return (
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup).edge)}> <article
className={cn(
"rounded-xl border border-l-4 bg-card p-4 shadow-card",
// L12: the card whose clock runs is highlighted by edge AND text (never by colour alone)
clock === "running" || clock === "en_route" ? "border-l-8 border-l-[var(--ok)] ring-2 ring-[color-mix(in_oklch,var(--ok)_35%,transparent)]" : clock === "paused" ? "border-l-8 border-l-[var(--warn)]" : toneClasses(order.statusGroup).edge,
)}
>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span> <span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status} /> <StatusBadge status={order.status} />
</div> </div>
{clock && (
<p className={cn("mt-2 inline-flex items-center gap-1.5 text-[14px] font-bold", clock === "paused" ? "text-[var(--warn)]" : "text-[var(--ok)]")}>
<Timer className="size-4.5" aria-hidden />
{clock === "paused" ? t("paused") : clock === "en_route" ? t("enRoute") : t("running")}
</p>
)}
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2> <h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
<p className="mt-0.5 text-[15px] font-semibold text-foreground">{order.customerName}</p> <p className="mt-0.5 text-[15px] font-semibold text-foreground">{order.customerName}</p>
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && ( {(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
@@ -52,10 +66,13 @@ export function OrderCard({ order }: { order: OrderCardData }) {
</div> </div>
)} )}
</dl> </dl>
<Link href={`/m/orders/${order.id}`} className={cn(btnPrimary, "mt-4")}> <div className="mt-4 space-y-2">
{canExecute && <CardTimeButton workOrderId={order.id} number={order.number} status={order.status} mySession={order.mySession} />}
<Link href={`/m/orders/${order.id}`} className={canExecute && order.mySession ? btnSecondary : btnPrimary}>
{t("open")} {t("open")}
<ChevronRight className="size-5" aria-hidden /> <ChevronRight className="size-5" aria-hidden />
</Link> </Link>
</div>
</article> </article>
); );
} }
@@ -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 (
<div className="px-4 pt-4">
<p className={noticeWarn} role="note">
<Clock className="mt-0.5 size-4.5 shrink-0" aria-hidden />
<span>
{pending.pendingEntries > 0 && t("report", { duration: fmtDuration(pending.pendingMinutes * 60) })}
{pending.pendingEntries > 0 && pending.openProposals > 0 && " "}
{pending.openProposals > 0 && t("proposals", { count: pending.openProposals })}
</span>
</p>
</div>
);
}
+112 -49
View File
@@ -3,90 +3,93 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; 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 type { WorkOrderStatus } from "@/lib/work-orders/status";
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
import { currentPosition } from "@/lib/field/image"; import { cn } from "@/lib/utils";
import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui"; import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
import { useSessionStart } from "./switch-session-sheet";
type SessionState = "en_route" | "running" | "paused" | null; 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"]; 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 } { * One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter.
if (status === "assigned") return { primary: "accept", secondary: null }; * L12: with an own session the primary action is Pause (or Weiter), secondary „Für heute beenden",
if (status === "accepted") return { primary: "travel", secondary: "start" }; * „Abschließen" as a separate text link (`link`) — max. 2 large buttons + 1 link.
if (status === "en_route") return { primary: "start", secondary: null }; */
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 (WORKING.includes(status)) {
if (mySession === "running") return { primary: "complete", secondary: "pause" }; if (mySession === "running") return { primary: "pause", secondary: "stop_day", link: "complete" };
if (mySession === "paused") return { primary: "resume", secondary: "complete" }; if (mySession === "paused") return { primary: "resume", secondary: "stop_day", link: "complete" };
return { primary: "start", secondary: status === "in_progress" ? "complete" : null }; 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; 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;
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 ?? {}),
};
}
export function PrimaryAction({ export function PrimaryAction({
workOrderId, workOrderId,
number,
status, status,
version, version,
mySession, mySession,
segmentType,
blockers, blockers,
}: { }: {
workOrderId: string; workOrderId: string;
number: string;
status: WorkOrderStatus; status: WorkOrderStatus;
version: number; version: number;
mySession: SessionState; 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) */ /** translated completion blockers (without the user's own session, which is ended on completion) */
blockers: string[]; blockers: string[];
}) { }) {
const t = useTranslations("field"); const t = useTranslations("field");
const router = useRouter(); const router = useRouter();
const [busy, setBusy] = useState<ActionKey | null>(null); const [busy, setBusy] = useState<ActionKey | SegmentKey | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirm, setConfirm] = useState(false); const [confirm, setConfirm] = useState<"complete" | "stop_day" | null>(null);
const { primary, secondary } = resolveActions(status, mySession); 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) { async function run(action: ActionKey) {
if (action === "complete" && !confirm) { if ((action === "complete" || action === "stop_day") && confirm !== action) {
setConfirm(true); 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; return;
} }
setBusy(action); setBusy(action);
setError(null);
const at = new Date().toISOString(); const at = new Date().toISOString();
let result; let result;
switch (action) { switch (action) {
case "accept": case "accept":
result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } }); result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } });
break; 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": case "pause":
result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } }); result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } });
break; break;
case "resume": case "stop_day":
result = await submitOp({ opType: "session.resume", payload: { workOrderId, at } }); result = await submitOp({ opType: "session.stop_day", payload: { workOrderId, at } });
break; break;
case "complete": { case "complete": {
let base = version; let base = version;
@@ -103,7 +106,16 @@ export function PrimaryAction({
} }
} }
setBusy(null); 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)}`)); if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
router.refresh(); router.refresh();
} }
@@ -113,33 +125,83 @@ export function PrimaryAction({
} }
const completeBlocked = blockers.length > 0; const completeBlocked = blockers.length > 0;
const anyBusy = busy !== null || starter.busy !== null;
const renderButton = (action: ActionKey, variant: "primary" | "secondary") => { const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
const Icon = busy === action ? LoaderCircle : ICONS[action]; const loading = busy === action || (starter.busy !== null && (action === "travel" || action === "start" || action === "resume"));
const disabled = busy !== null || (action === "complete" && completeBlocked); 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 ( return (
<button type="button" onClick={() => run(action)} disabled={disabled} className={variant === "primary" ? btnPrimary : btnSecondary}> <button type="button" onClick={() => run(action)} disabled={disabled} className={variant === "primary" ? btnPrimary : btnSecondary}>
<Icon className={busy === action ? "size-5 animate-spin" : "size-5"} aria-hidden /> <Icon className={loading ? "size-5 animate-spin" : "size-5"} aria-hidden />
{busy === action ? t("action.saving") : action === "complete" && confirm ? t("action.confirmComplete") : t(`action.${action}`)} {label}
</button> </button>
); );
}; };
// 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 ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
{renderButton(primary, "primary")} {renderButton(primary, "primary")}
{secondary && renderButton(secondary, "secondary")} {secondary && renderButton(secondary, "secondary")}
{confirm && ( {segments.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{mySession === "running" && segmentType && segmentType !== "work" && (
<p className="w-full text-[13px] font-semibold text-muted-foreground">{t("action.currentSegment", { type: t(`time.type.${segmentType}`) })}</p>
)}
{segments.map((s) => {
const Icon = busy === s ? LoaderCircle : SEGMENT_ICONS[s];
return (
<button
key={s}
type="button"
disabled={anyBusy}
onClick={() => segment(s)}
className="inline-flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3 text-[14px] font-semibold text-primary disabled:opacity-50"
>
<Icon className={cn("size-4.5", busy === s && "animate-spin")} aria-hidden />
{t(`action.segment.${s}`)}
</button>
);
})}
</div>
)}
{link === "complete" && confirm !== "complete" && (
<button type="button" disabled={anyBusy || completeBlocked} onClick={() => run("complete")} className="inline-flex min-h-12 items-center px-1 text-[15px] font-semibold text-primary underline underline-offset-4 disabled:opacity-50">
{t("action.completeLink")}
</button>
)}
{confirm === "complete" && (
<>
{renderButton("complete", "secondary")}
<div className={noticeWarn}> <div className={noticeWarn}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden /> <TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
<span> <span>
{t("action.confirmHint")}{" "} {t("action.confirmHint")}{" "}
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(false)}> <button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(null)}>
{t("photos.discard")}
</button>
</span>
</div>
</>
)}
{confirm === "stop_day" && (
<div className={noticeWarn}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
<span>
{t("action.stopDayHint")}{" "}
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(null)}>
{t("photos.discard")} {t("photos.discard")}
</button> </button>
</span> </span>
</div> </div>
)} )}
{completeBlocked && (primary === "complete" || secondary === "complete") && ( {completeBlocked && (primary === "complete" || secondary === "complete" || link === "complete") && (
<div className={noticeWarn} role="note"> <div className={noticeWarn} role="note">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden /> <TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
<div> <div>
@@ -158,6 +220,7 @@ export function PrimaryAction({
{error} {error}
</p> </p>
)} )}
{starter.sheet}
</div> </div>
); );
} }
+165
View File
@@ -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<MyActiveSession, "status" | "workOrderId" | "number" | "title" | "segmentStartedAt" | "closedSeconds"> & { 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<ClockSession | null> {
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<ClockSession | null | undefined>(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 (
<section
aria-label={t("label")}
className={cn(
"fixed inset-x-0 z-30 border-t border-l-4 bg-card shadow-card",
"bottom-[calc(4rem+1px+env(safe-area-inset-bottom))]",
session.status === "paused" ? "border-l-[var(--warn)]" : "border-l-[var(--ok)]",
)}
>
<div className="mx-auto flex max-w-xl items-center gap-2 px-3 py-2">
<Link href={`/m/orders/${session.workOrderId}`} aria-label={t("openOrder", { number: session.number })} className="flex min-h-12 min-w-0 flex-1 flex-col justify-center">
<span className="flex items-center gap-1.5 text-[12px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className={cn("inline-block size-2 rounded-full", session.status === "paused" ? "bg-[var(--warn)]" : "animate-pulse bg-[var(--ok)]")} aria-hidden />
{statusLabel}
{offline && ` · ${t("offline")}`}
</span>
<span className="truncate text-[15px] font-semibold">
{session.number} · {session.title}
{!offline && <span className="tabular-nums"> · {fmtClock(elapsed(session, now))}</span>}
</span>
</Link>
<Link href="/m/time" aria-label={t("myTime")} className={cn(btn, "text-primary")}>
<History className="size-5" aria-hidden />
</Link>
{session.status === "paused" ? (
<button type="button" className={cn(btn, "bg-cta text-cta-foreground")} disabled={resume.busy !== null} onClick={() => resume.start("resume")}>
{resume.busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Play className="size-5" aria-hidden />}
{t("resume")}
</button>
) : session.status === "running" ? (
<button type="button" className={cn(btn, "border border-border text-primary")} disabled={busy !== null} onClick={pause}>
{busy === "pause" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Pause className="size-5" aria-hidden />}
{t("pause")}
</button>
) : null}
<button
type="button"
className={cn(btn, confirmStop ? "bg-destructive text-primary-foreground" : "border border-border text-foreground")}
disabled={busy !== null}
onClick={stopDay}
onBlur={() => setConfirmStop(false)}
aria-label={t("stopDay")}
>
{busy === "stop" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Square className="size-4.5" aria-hidden />}
<span className="max-[400px]:sr-only">{confirmStop ? t("stopDayConfirm") : t("stopDay")}</span>
</button>
</div>
{resume.sheet}
</section>
);
}
@@ -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<SyncOpResult> {
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<StartKind | null>(null);
const [pending, setPending] = useState<{ kind: StartKind; other: OtherSession } | null>(null);
const [result, setResult] = useState<SyncOpResult | null>(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 ? (
<SwitchSessionSheet
from={pending.other.number}
to={number}
busy={busy !== null}
onConfirm={() => 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 (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40" onClick={onCancel}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="switch-session-title"
className="w-full max-w-xl space-y-4 rounded-t-2xl bg-card p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3">
<ArrowLeftRight className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
<div>
<h2 id="switch-session-title" className="text-[19px]">
{t("title")}
</h2>
<p className="mt-1 text-[16px]">{from ? t("text", { from, to }) : t("textUnknown", { to })}</p>
</div>
</div>
<button type="button" className={btnPrimary} disabled={busy} onClick={onConfirm} autoFocus>
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <ArrowLeftRight className="size-5" aria-hidden />}
{t("confirm")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={onCancel}>
{t("cancel")}
</button>
</div>
</div>
);
}
@@ -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(
<span key="manual" className={`${badge} bg-muted text-foreground`}>
<PenLine className="size-3.5" aria-hidden />
{t("manual")}
</span>,
);
}
if (entry.approvalStatus === "pending") {
items.push(
<span key="pending" className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
<Clock className="size-3.5" aria-hidden />
{t("pending")}
</span>,
);
}
if (entry.approvalStatus === "rejected") {
items.push(
<span key="rejected" className={`${badge} bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]`}>
<TriangleAlert className="size-3.5" aria-hidden />
{t("rejected", { reason: entry.rejectionReason ?? "" })}
</span>,
);
}
if (entry.approvalStatus === "approved" && entry.pendingChange) {
items.push(
<span key="proposal" className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
<Clock className="size-3.5" aria-hidden />
{t("correctionRequested")} ·{" "}
{tt("myTime.proposed", { from: fmtTime(entry.pendingChange.startedAt, locale), to: fmtTime(entry.pendingChange.endedAt, locale), type: tt(`time.type.${entry.pendingChange.type}`) })}
</span>,
);
}
if (entry.approvalStatus === "approved" && !entry.pendingChange && entry.rejectionReason) {
items.push(
<span key="proposalRejected" className={`${badge} bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]`}>
<TriangleAlert className="size-3.5" aria-hidden />
{t("correctionRejected", { reason: entry.rejectionReason })}
</span>,
);
}
if (entry.corrected) {
items.push(
<span key="corrected" className={`${badge} bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]`}>
<CircleCheck className="size-3.5" aria-hidden />
{t("corrected")}
</span>,
);
}
if (!items.length) return null;
return <div className="flex flex-wrap gap-1.5">{items}</div>;
}
+6 -4
View File
@@ -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 }) { function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view"); const t = useTranslations("offline.view");
const tf = useTranslations("field");
const { feedback, report } = useFeedback(); const { feedback, report } = useFeedback();
const [busy, setBusy] = useState<Action | null>(null); const [busy, setBusy] = useState<Action | null>(null);
const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session); const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session);
const actions = [resolved.primary, resolved.secondary] const actions = [resolved.primary, resolved.secondary]
.map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a)) .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); .filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i);
const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete"; // L12: „Abschließen" is a text link in resolveActions; offline it stays a hint
const labels: Record<Action, string> = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd") }; const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete" || resolved.link === "complete";
const labels: Record<Action, string> = { 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) { async function run(action: Action) {
setBusy(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" } }) ? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } })
: action === "travel" || action === "start" : action === "travel" || action === "start"
? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") }) ? 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); setBusy(null);
report(result); report(result);
onChanged(); onChanged();
+57 -2
View File
@@ -27,6 +27,7 @@ import {
removePhotoRequirementAction, removePhotoRequirementAction,
} from "@/server/actions/work_orders/work-orders"; } from "@/server/actions/work_orders/work-orders";
import { formatDate, formatDateTime } from "@/lib/work-orders/time"; 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 { ActionForm } from "@/components/work-orders/action-form";
import { buttonCls } from "@/components/work-orders/button-cls"; import { buttonCls } from "@/components/work-orders/button-cls";
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui"; 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 t = await getTranslations("workOrders");
const sessions = await getTimesTab(ctx, wo.id); const sessions = await getTimesTab(ctx, wo.id);
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>; if (sessions.length === 0) return <Empty>{t("times.empty")}</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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-semibold">
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
</span>
{pendingTotal > 0 && <span className="font-semibold text-[var(--warn)]">{t("times.pendingMinutes", { minutes: pendingTotal })}</span>}
{canApprove && openDecisions > 0 && (
<Link href="/work-orders/time-approvals" className="font-semibold text-[var(--primary)] hover:underline">
{t("times.openApprovals")}
</Link>
)}
</div>
{sessions.map((s) => ( {sessions.map((s) => (
<Section key={s.id} title={`${s.user.name} · ${t(`times.sessionStatus.${s.status}`)}`} actions={<span className="text-sm font-semibold">{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}</span>}> <Section
key={s.id}
title={`${s.user.name} · ${s.manual ? t("times.manualSession") : t(`times.sessionStatus.${s.status}`)}`}
actions={
<span className="text-sm font-semibold">
{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}
{s.pendingMinutes > 0 && <span className="ml-2 text-[var(--warn)]">{t("times.pendingMinutes", { minutes: s.pendingMinutes })}</span>}
</span>
}
>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-sm"> <table className="w-full min-w-[520px] text-sm">
<thead className="border-b text-left text-xs text-muted-foreground"> <thead className="border-b text-left text-xs text-muted-foreground">
@@ -320,7 +346,36 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td> <td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td> <td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td> <td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
<td className="py-1.5 text-xs text-[var(--warn)]">{e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""}</td> <td className="space-y-1 py-1.5 text-xs">
<div className="flex flex-wrap gap-1">
{e.source === "manual" && <span className={`${badge} bg-muted text-foreground`}>{t("times.badgeManual")}</span>}
{e.approvalStatus === "pending" && <span className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>{t("times.badgePending")}</span>}
{e.approvalStatus === "rejected" && <span className={`${badge} text-[var(--risk)]`}>{t("times.badgeRejected", { reason: e.rejectionReason ?? "" })}</span>}
{e.pendingChange !== null && (
<span className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
{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),
})}
</span>
)}
</div>
{e.corrected && <p className="text-[var(--warn)]">{t("times.corrected", { reason: e.correctionReason ?? "" })}</p>}
{!e.corrected && e.source === "manual" && e.correctionReason && <p className="text-muted-foreground">{t("times.reason", { reason: e.correctionReason })}</p>}
{canApprove && e.userId !== ctx.userId && (e.approvalStatus === "pending" || e.pendingChange !== null) && (
<div className="flex flex-wrap items-start gap-2 pt-1">
<ActionForm action={approveTimeEntryAction} submitLabel={t("times.approve")} variant="primary" successText={t("times.approved")} footerClassName="mt-0">
<Hidden name="timeEntryId" value={e.id} />
<Hidden name="workOrderId" value={wo.id} />
</ActionForm>
<ActionForm action={rejectTimeEntryAction} submitLabel={t("times.reject")} variant="outline" successText={t("times.rejected")} className="flex flex-wrap items-start gap-2" footerClassName="mt-0">
<Hidden name="timeEntryId" value={e.id} />
<Hidden name="workOrderId" value={wo.id} />
<input name="reason" required minLength={3} maxLength={500} placeholder={t("times.rejectReason")} aria-label={t("times.rejectReason")} className={inputCls} />
</ActionForm>
</div>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -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<ActionState>;
/**
* 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 (
<form id={id} action={formAction} className="flex flex-wrap items-center gap-3">
<button type="submit" disabled={pending} className={buttonCls("primary")}>
{label}
</button>
{state.status === "error" && (
<span role="alert" className="flex items-center gap-1.5 text-sm text-[var(--risk)]">
<AlertCircle className="size-4" aria-hidden />
{t.has(`errors.${state.message}`) ? t(`errors.${state.message}`) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal")}
</span>
)}
{state.status === "ok" && (
<span role="status" className="flex items-center gap-1.5 text-sm text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden />
{successText}
</span>
)}
</form>
);
}
/** „Alle auswählen" for the checkboxes of one bulk form. */
export function SelectAllBox({ formId, label }: { formId: string; label: string }) {
return (
<input
type="checkbox"
aria-label={label}
className="size-5"
onChange={(e) => {
document.querySelectorAll<HTMLInputElement>(`input[type=checkbox][form="${formId}"][name="ids"]`).forEach((box) => {
box.checked = e.currentTarget.checked;
});
}}
/>
);
}
+2
View File
@@ -14,6 +14,7 @@ import {
ListChecks, ListChecks,
Siren, Siren,
Compass, Compass,
Timer,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { ModuleKey } from "@/lib/modules"; 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"], permissions: ["work_order:read_all", "work_order:read_team"],
section: "main", 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: "/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: "/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" }, { href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
+34 -4
View File
@@ -4,10 +4,18 @@ import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard"; import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context"; import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { correctTimeEntry } from "@/server/services/field/time-correction"; import { correctTimeEntry } from "@/server/services/field/time-correction";
import { approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries";
const guard = moduleGuard("field"); 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. */ /** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */
export async function correctTime(input: { export async function correctTime(input: {
@@ -26,10 +34,32 @@ export async function correctTime(input: {
reason: input.reason, reason: input.reason,
}); });
} catch (err) { } catch (err) {
if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code }; return toResult(err, "time correction");
console.error("[field] time correction failed:", err);
return { ok: false, error: "failed" };
} }
revalidatePath(`/m/orders/${input.workOrderId}/time`); revalidatePath(`/m/orders/${input.workOrderId}/time`);
return { ok: true }; 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<TimeCorrectionResult> {
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<TimeCorrectionResult> {
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 };
}
@@ -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<ActionState> {
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<ActionState> {
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<ActionState> {
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" });
}
}