From fe3eba6e44a531c2f3bb4dcd9192ada8be35aa67 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:29:38 +0200 Subject: [PATCH] L12 Zeiterfassung: Mobile Uhr-Leiste, Meine Zeiten, Freigaben und Backoffice-Liste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laufende-Uhr-Leiste auf allen /m-Seiten, Start/Pause direkt auf den Auftragskarten, Auto-Wechsel-Dialog, Auftragsdetail mit Pause / Für heute beenden / Abschließen-Link und Segmentwechseln, /m/time (Tagesübersicht, Nachtragen, Korrektur vorschlagen), /m/approvals für Teamleiter, /work-orders/time-approvals mit Sammelfreigabe, Zeiten-Tab mit Badges und Inline-Freigabe, Dashboard-Kachel, Texte de/en. Co-Authored-By: Claude Opus 5 --- messages/de/dashboard.json | 6 +- messages/de/field.json | 154 ++++++++++++- messages/de/nav.json | 3 +- messages/de/notifications.json | 102 +++++++-- messages/de/offline.json | 5 +- messages/de/workOrders.json | 47 +++- messages/en/dashboard.json | 6 +- messages/en/field.json | 154 ++++++++++++- messages/en/nav.json | 3 +- messages/en/notifications.json | 102 +++++++-- messages/en/offline.json | 5 +- messages/en/workOrders.json | 47 +++- src/app/(app)/dashboard/page.tsx | 10 +- .../(app)/work-orders/time-approvals/page.tsx | 116 ++++++++++ src/app/(field)/m/(core)/approvals/page.tsx | 75 +++++++ src/app/(field)/m/(core)/orders/[id]/page.tsx | 7 +- .../m/(core)/orders/[id]/report/page.tsx | 10 +- .../m/(core)/orders/[id]/time/page.tsx | 62 ++++-- src/app/(field)/m/(core)/orders/page.tsx | 4 +- src/app/(field)/m/(core)/page.tsx | 4 +- src/app/(field)/m/(core)/profile/page.tsx | 14 +- src/app/(field)/m/(core)/time/new/page.tsx | 35 +++ src/app/(field)/m/(core)/time/page.tsx | 105 +++++++++ src/app/(field)/m/layout.tsx | 31 ++- src/components/field/approval-actions.tsx | 73 ++++++ src/components/field/bottom-nav.tsx | 16 +- src/components/field/card-time-button.tsx | 56 +++++ .../field/correction-proposal-form.tsx | 150 +++++++++++++ src/components/field/local-pending-times.tsx | 64 ++++++ src/components/field/manual-time-form.tsx | 207 ++++++++++++++++++ src/components/field/order-card.tsx | 33 ++- src/components/field/pending-time-notice.tsx | 40 ++++ src/components/field/primary-action.tsx | 163 +++++++++----- src/components/field/running-clock-bar.tsx | 165 ++++++++++++++ src/components/field/switch-session-sheet.tsx | 106 +++++++++ src/components/field/time-entry-badges.tsx | 72 ++++++ src/components/offline/offline-view.tsx | 10 +- src/components/work-orders/detail-tabs.tsx | 59 ++++- .../work-orders/time-approval-forms.tsx | 53 +++++ src/lib/nav.ts | 2 + src/server/actions/field/time.ts | 38 +++- .../actions/work_orders/time-approvals.ts | 71 ++++++ 42 files changed, 2319 insertions(+), 166 deletions(-) create mode 100644 src/app/(app)/work-orders/time-approvals/page.tsx create mode 100644 src/app/(field)/m/(core)/approvals/page.tsx create mode 100644 src/app/(field)/m/(core)/time/new/page.tsx create mode 100644 src/app/(field)/m/(core)/time/page.tsx create mode 100644 src/components/field/approval-actions.tsx create mode 100644 src/components/field/card-time-button.tsx create mode 100644 src/components/field/correction-proposal-form.tsx create mode 100644 src/components/field/local-pending-times.tsx create mode 100644 src/components/field/manual-time-form.tsx create mode 100644 src/components/field/pending-time-notice.tsx create mode 100644 src/components/field/running-clock-bar.tsx create mode 100644 src/components/field/switch-session-sheet.tsx create mode 100644 src/components/field/time-entry-badges.tsx create mode 100644 src/components/work-orders/time-approval-forms.tsx create mode 100644 src/server/actions/work_orders/time-approvals.ts diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 33e7ba1..7388acd 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -16,7 +16,8 @@ "billing": "Zur Abrechnung", "emergency_new": "Neue Notdienste", "missing_signatures": "Fehlende Unterschriften", - "sync_conflicts": "Sync-Konflikte" + "sync_conflicts": "Sync-Konflikte", + "time_approvals": "Zeiten zur Freigabe" }, "hints": { "open": "Noch nicht abgerechnet oder storniert", @@ -29,7 +30,8 @@ "billing": "Bereit zur Abrechnung", "emergency_new": "Neu oder zur Prüfung", "missing_signatures": "Unterschrift ausstehend", - "sync_conflicts": "Offline-Änderungen prüfen" + "sync_conflicts": "Offline-Änderungen prüfen", + "time_approvals": "Nachträge und Korrekturen prüfen" }, "open": "Anzeigen" } diff --git a/messages/de/field.json b/messages/de/field.json index ba53ccf..c1b3f21 100644 --- a/messages/de/field.json +++ b/messages/de/field.json @@ -5,7 +5,8 @@ "orders": "Aufträge", "emergency": "Notdienst", "sync": "Sync", - "profile": "Profil" + "profile": "Profil", + "approvalsBadge": "{count} Zeiten zur Freigabe" }, "connection": { "online": "Online", @@ -35,7 +36,13 @@ "noDate": "Ohne Termin", "emergency": "Notdienst", "urgent": "Dringend", - "high": "Hoch" + "high": "Hoch", + "running": "Zeit läuft", + "paused": "Pausiert", + "enRoute": "In Anfahrt", + "startWork": "Arbeit starten", + "pause": "Pause", + "resume": "Weiter" }, "statusGroup": { "new": "Neu", @@ -78,7 +85,17 @@ "saving": "Wird gespeichert …", "blocked": "Vor dem Abschluss noch offen:", "reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.", - "nothing": "Keine Aktion erforderlich." + "nothing": "Keine Aktion erforderlich.", + "stop_day": "Für heute beenden", + "confirmStopDay": "Jetzt für heute beenden", + "stopDayHint": "Die Zeiterfassung endet, der Auftrag bleibt offen und kann morgen fortgesetzt werden.", + "completeLink": "Auftrag abschließen …", + "segment": { + "return_travel": "Rückfahrt starten", + "material_procurement": "Material holen", + "work": "Zurück zur Arbeit" + }, + "currentSegment": "Aktuell: {type}" }, "blocker": { "checklist_item": "Checkliste: {label}", @@ -264,7 +281,8 @@ "en_route": "In Anfahrt", "running": "Läuft", "paused": "Pausiert", - "ended": "Beendet" + "ended": "Beendet", + "manual": "Nachgetragen" }, "running": "läuft", "offlineStarted": "offline gestartet", @@ -277,7 +295,10 @@ "save": "Korrektur speichern", "saved": "Korrektur gespeichert.", "empty": "Noch keine Zeiten erfasst.", - "totalWork": "Arbeitszeit gesamt: {duration}" + "totalWork": "Arbeitszeit gesamt: {duration}", + "propose": "Korrektur vorschlagen", + "totalPending": "Zur Freigabe offen: {duration}", + "ownEntryHint": "Eigene Zeiten korrigieren Sie über „Korrektur vorschlagen“." }, "profile": { "title": "Profil", @@ -287,12 +308,133 @@ "roles": "Rolle", "language": "Sprache", "logout": "Abmelden", - "backoffice": "Zum Büro-Bereich" + "backoffice": "Zum Büro-Bereich", + "myTime": "Meine Zeiten", + "approvals": "Zeiten freigeben" }, "sync": { "title": "Synchronisation", "immediate": "Änderungen werden sofort an das Büro übertragen.", "offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.", "status": "Verbindung" + }, + "clock": { + "label": "Laufende Zeiterfassung", + "running": "Läuft", + "paused": "Pausiert", + "enRoute": "Anfahrt", + "openOrder": "Auftrag {number} öffnen", + "pause": "Pause", + "resume": "Weiter", + "stopDay": "Für heute beenden", + "stopDayConfirm": "Beenden?", + "myTime": "Meine Zeiten", + "offline": "offline" + }, + "switch": { + "title": "Andere Zeiterfassung läuft", + "text": "{from} läuft noch. Pausieren und {to} starten?", + "textUnknown": "Auf einem anderen Auftrag läuft noch eine Zeiterfassung. Pausieren und {to} starten?", + "confirm": "Wechseln", + "cancel": "Abbrechen" + }, + "myTime": { + "title": "Meine Zeiten", + "sub": "Ihre Zeiten je Tag über alle Aufträge.", + "today": "Heute", + "yesterday": "Gestern", + "days": "Tag wählen", + "empty": "An diesem Tag sind keine Zeiten erfasst.", + "approved": "Freigegeben", + "pending": "Zur Freigabe", + "dayTotal": "Tagessumme", + "totalApproved": "Freigegeben: {duration}", + "totalPending": "Offen: {duration}", + "add": "Zeit nachtragen", + "running": "läuft", + "badge": { + "manual": "Manuell", + "pending": "Zur Freigabe", + "rejected": "Abgelehnt: {reason}", + "correctionRequested": "Korrektur beantragt", + "correctionRejected": "Korrektur abgelehnt: {reason}", + "corrected": "Korrigiert" + }, + "proposed": "Vorschlag: {from}–{to} · {type}", + "localPending": "Wartet auf Übertragung", + "localTitle": "Noch nicht übertragen", + "form": { + "title": "Zeit nachtragen", + "order": "Auftrag", + "orderPlaceholder": "Auftrag wählen", + "noOrders": "Keine Aufträge der letzten 7 Tage verfügbar.", + "forUser": "Für", + "me": "Mich selbst", + "type": "Art", + "date": "Datum", + "mode": "Erfassung", + "modeRange": "Von – Bis", + "modeDuration": "Dauer", + "from": "Von", + "to": "Bis", + "duration": "Dauer in Minuten", + "quick": "{minutes} min", + "reason": "Begründung", + "reasonHint": "Pflichtangabe, mindestens 3 Zeichen.", + "note": "Notiz (optional)", + "approvalHint": "Wird nach Freigabe durch Teamleiter oder Büro gezählt.", + "directHint": "Für Teammitglieder erfasste Zeiten gelten sofort als freigegeben.", + "save": "Nachtrag speichern", + "saved": "Nachtrag gespeichert – wartet auf Freigabe.", + "savedDirect": "Zeit gespeichert.", + "queued": "Offline gespeichert – wird übertragen, sobald Verbindung besteht.", + "cancel": "Abbrechen" + }, + "correction": { + "title": "Korrektur vorschlagen", + "hint": "Bis zur Freigabe gilt die bisherige Zeit.", + "save": "Vorschlag senden", + "saved": "Korrektur beantragt." + }, + "reasons": { + "forgotStart": "Start vergessen", + "noNetwork": "Kein Netz", + "recordedLater": "Nachträglich erfasst" + }, + "errors": { + "overlap": "Der Zeitraum überschneidet sich mit einer bereits erfassten Zeit.", + "too_old": "Nachtragen ist nur für die letzten 7 Tage möglich.", + "in_future": "Die Zeit darf nicht in der Zukunft liegen.", + "too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.", + "end_before_start": "Das Ende muss nach dem Beginn liegen.", + "work_order_status": "Für diesen Auftrag können keine Zeiten mehr erfasst werden.", + "not_own_entry": "Sie können nur eigene Zeiten korrigieren.", + "own_entry": "Eigene Zeiten können Sie nicht selbst freigeben.", + "entry_running": "Die Zeit läuft noch." + } + }, + "approvals": { + "title": "Zeiten freigeben", + "sub": "Nachträge und Korrekturen Ihres Teams.", + "empty": "Keine offenen Nachträge oder Korrekturen.", + "link": "Zeiten freigeben", + "count": "{count, plural, =0 {Nichts offen} one {# offen} other {# offen}}", + "manual": "Nachtrag", + "correction": "Korrektur", + "reason": "Begründung", + "note": "Notiz", + "old": "Bisher", + "new": "Neu", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "rejectConfirm": "Ablehnung senden", + "cancel": "Abbrechen", + "approved": "Freigegeben.", + "rejected": "Abgelehnt." + }, + "pendingTime": { + "report": "Hinweis: {duration} nachgetragene Zeit ist noch nicht freigegeben und fehlt im Bericht.", + "proposals": "Hinweis: {count, plural, one {# Zeitkorrektur wartet} other {# Zeitkorrekturen warten}} auf Freigabe." } } diff --git a/messages/de/nav.json b/messages/de/nav.json index efed3cc..f49d785 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -16,5 +16,6 @@ "lotse": "Lotse (KI)", "admin": "Admin-Konsole", "openMenu": "Menü öffnen", - "closeMenu": "Menü schließen" + "closeMenu": "Menü schließen", + "timeApprovals": "Zeiten zur Freigabe" } diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 3689c31..5685b8c 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -45,26 +45,92 @@ "emergency_completed": "Notdiensteinsatz abgeschlossen", "import_ready_for_review": "Import zur Prüfung", "import_failed": "Fehler bei Dokumentverarbeitung", - "sync_failed": "Synchronisationsfehler" + "sync_failed": "Synchronisationsfehler", + "time_approval_requested": "Zeit zur Freigabe", + "time_approved": "Zeit freigegeben", + "time_rejected": "Zeit abgelehnt" }, "events": { - "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, - "work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." }, - "work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." }, - "work_order_started": { "title": "Auftrag {number} gestartet", "message": "{actor} hat den Einsatz bei {customer} begonnen." }, - "work_order_daily_report_created": { "title": "Tagesbericht zu {number}", "message": "{actor} hat einen Tagesbericht für {customer} erstellt." }, - "work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." }, - "work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." }, - "work_order_missing_required": { "title": "Pflichtangaben fehlen: {number}", "message": "{title} – {customer}: Dokumentation unvollständig." }, - "work_order_released_for_billing": { "title": "Auftrag {number} zur Abrechnung", "message": "{title} – {customer} ist bereit zur Abrechnung." }, - "report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." }, - "report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." }, - "report_rejected": { "title": "Bericht zurückgewiesen: {number}", "message": "Der Bericht zu {title} braucht eine Korrektur." }, - "emergency_created": { "title": "Notdiensteinsatz {number} erstellt", "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." }, - "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, - "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, - "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." }, - "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." } + "work_order_assigned": { + "title": "Neuer Auftrag {number}", + "message": "{title} – {customer} wurde Ihrem Team zugewiesen." + }, + "work_order_changed": { + "title": "Auftrag {number} geändert", + "message": "{title} – {customer}: Angaben wurden aktualisiert." + }, + "work_order_cancelled": { + "title": "Auftrag {number} storniert", + "message": "{title} – {customer} wurde storniert." + }, + "work_order_started": { + "title": "Auftrag {number} gestartet", + "message": "{actor} hat den Einsatz bei {customer} begonnen." + }, + "work_order_daily_report_created": { + "title": "Tagesbericht zu {number}", + "message": "{actor} hat einen Tagesbericht für {customer} erstellt." + }, + "work_order_technically_completed": { + "title": "Auftrag {number} technisch abgeschlossen", + "message": "{title} – {customer}: Einsatz vor Ort beendet." + }, + "work_order_signature_missing": { + "title": "Unterschrift fehlt: {number}", + "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." + }, + "work_order_missing_required": { + "title": "Pflichtangaben fehlen: {number}", + "message": "{title} – {customer}: Dokumentation unvollständig." + }, + "work_order_released_for_billing": { + "title": "Auftrag {number} zur Abrechnung", + "message": "{title} – {customer} ist bereit zur Abrechnung." + }, + "report_submitted": { + "title": "Bericht zur Prüfung: {number}", + "message": "{actor} hat einen Bericht zu {title} eingereicht." + }, + "report_approved": { + "title": "Bericht freigegeben: {number}", + "message": "Der Bericht zu {title} wurde freigegeben." + }, + "report_rejected": { + "title": "Bericht zurückgewiesen: {number}", + "message": "Der Bericht zu {title} braucht eine Korrektur." + }, + "emergency_created": { + "title": "Notdiensteinsatz {number} erstellt", + "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." + }, + "emergency_completed": { + "title": "Notdiensteinsatz {number} abgeschlossen", + "message": "{customer}: zur Prüfung und Abrechnung." + }, + "import_ready_for_review": { + "title": "Import zur Prüfung bereit", + "message": "Die Daten aus {fileName} liegen zur Prüfung vor." + }, + "import_failed": { + "title": "Fehler bei Dokumentverarbeitung", + "message": "{fileName} konnte nicht verarbeitet werden." + }, + "sync_failed": { + "title": "Synchronisation fehlgeschlagen", + "message": "Eine Änderung konnte nicht übernommen werden ({reason})." + }, + "time_approval_requested": { + "title": "Zeit zur Freigabe: {number}", + "message": "{actor} hat eine Zeit zu {number} nachgetragen oder eine Korrektur beantragt." + }, + "time_approved": { + "title": "Zeit freigegeben: {number}", + "message": "{actor} hat Ihre Zeit zu {number} freigegeben." + }, + "time_rejected": { + "title": "Zeit abgelehnt: {number}", + "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}" + } }, "fallback": { "unknown": "unbekannt", diff --git a/messages/de/offline.json b/messages/de/offline.json index 07a4bbb..89336f8 100644 --- a/messages/de/offline.json +++ b/messages/de/offline.json @@ -89,7 +89,10 @@ "upload": "Die Datei wurde nicht angenommen (Format oder Größe).", "unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.", "network": "Keine Verbindung – wird automatisch erneut versucht.", - "internal": "Technischer Fehler – wird automatisch erneut versucht." + "internal": "Technischer Fehler – wird automatisch erneut versucht.", + "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.", + "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.", + "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln." }, "view": { "title": "Offline-Ansicht", diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 0cb6433..94eb659 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -237,7 +237,21 @@ "material_procurement": "Materialbeschaffung", "return_travel": "Rückfahrt", "interruption": "Unterbrechung" - } + }, + "pendingMinutes": "Zur Freigabe offen: {minutes} min", + "approvedMinutes": "Freigegeben", + "badgeManual": "Manuell", + "badgePending": "Zur Freigabe", + "badgeRejected": "Abgelehnt: {reason}", + "badgeCorrection": "Korrektur beantragt: {from}–{to}", + "reason": "Begründung: {reason}", + "manualSession": "Nachgetragen", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund", + "approved": "Freigegeben.", + "rejected": "Abgelehnt.", + "openApprovals": "Alle offenen Freigaben" }, "photos": { "empty": "Noch keine Fotos.", @@ -460,8 +474,35 @@ "running_session": "Ein Einsatz läuft noch", "field": { "signature": "Unterschrift fehlt", - "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben" + "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben", + "pending_time_entries": "Zeiten warten noch auf Freigabe" } }, - "saved": "Gespeichert." + "saved": "Gespeichert.", + "timeApprovals": { + "crumb": "Aufträge", + "title": "Zeiten zur Freigabe", + "sub": "Nachträge und Korrekturen der Monteure. Erst nach Freigabe zählen sie für Bericht und Abrechnung.", + "empty": "Keine offenen Nachträge oder Korrekturen.", + "employee": "Mitarbeiter", + "order": "Auftrag", + "date": "Datum", + "range": "Von–Bis", + "duration": "Dauer", + "type": "Art", + "reason": "Begründung", + "kindManual": "Nachtrag", + "kindCorrection": "Korrektur", + "oldNew": "{old} → {new}", + "select": "Auswählen", + "selectAll": "Alle auswählen", + "approveSelected": "Ausgewählte freigeben", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "approved": "Freigegeben.", + "bulkApproved": "{ok} freigegeben, {failed} nicht möglich.", + "rejected": "Abgelehnt.", + "count": "{count, plural, one {# offener Eintrag} other {# offene Einträge}}" + } } diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index eee1199..3f36189 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -16,7 +16,8 @@ "billing": "Ready for billing", "emergency_new": "New emergencies", "missing_signatures": "Missing signatures", - "sync_conflicts": "Sync conflicts" + "sync_conflicts": "Sync conflicts", + "time_approvals": "Time approvals" }, "hints": { "open": "Not yet billed or cancelled", @@ -29,7 +30,8 @@ "billing": "Ready to invoice", "emergency_new": "New or in review", "missing_signatures": "Signature pending", - "sync_conflicts": "Review offline changes" + "sync_conflicts": "Review offline changes", + "time_approvals": "Review added time and corrections" }, "open": "Show" } diff --git a/messages/en/field.json b/messages/en/field.json index d8a5456..57a6825 100644 --- a/messages/en/field.json +++ b/messages/en/field.json @@ -5,7 +5,8 @@ "orders": "Orders", "emergency": "Emergency", "sync": "Sync", - "profile": "Profile" + "profile": "Profile", + "approvalsBadge": "{count} time entries to approve" }, "connection": { "online": "Online", @@ -35,7 +36,13 @@ "noDate": "No date", "emergency": "Emergency", "urgent": "Urgent", - "high": "High" + "high": "High", + "running": "Time running", + "paused": "Paused", + "enRoute": "On the way", + "startWork": "Start work", + "pause": "Pause", + "resume": "Resume" }, "statusGroup": { "new": "New", @@ -78,7 +85,17 @@ "saving": "Saving …", "blocked": "Still missing before completion:", "reportNext": "Report and signature follow in the next step.", - "nothing": "No action required." + "nothing": "No action required.", + "stop_day": "Stop for today", + "confirmStopDay": "Stop for today now", + "stopDayHint": "Time recording ends, the order stays open and can be continued tomorrow.", + "completeLink": "Complete order …", + "segment": { + "return_travel": "Start return trip", + "material_procurement": "Fetch material", + "work": "Back to work" + }, + "currentSegment": "Current: {type}" }, "blocker": { "checklist_item": "Checklist: {label}", @@ -264,7 +281,8 @@ "en_route": "On the way", "running": "Running", "paused": "Paused", - "ended": "Ended" + "ended": "Ended", + "manual": "Recorded later" }, "running": "running", "offlineStarted": "started offline", @@ -277,7 +295,10 @@ "save": "Save correction", "saved": "Correction saved.", "empty": "No time recorded yet.", - "totalWork": "Total working time: {duration}" + "totalWork": "Total working time: {duration}", + "propose": "Propose correction", + "totalPending": "Awaiting approval: {duration}", + "ownEntryHint": "Correct your own time via “Propose correction”." }, "profile": { "title": "Profile", @@ -287,12 +308,133 @@ "roles": "Role", "language": "Language", "logout": "Sign out", - "backoffice": "Go to office area" + "backoffice": "Go to office area", + "myTime": "My time", + "approvals": "Approve time" }, "sync": { "title": "Synchronisation", "immediate": "Changes are sent to the office immediately.", "offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.", "status": "Connection" + }, + "clock": { + "label": "Running time recording", + "running": "Running", + "paused": "Paused", + "enRoute": "Travel", + "openOrder": "Open order {number}", + "pause": "Pause", + "resume": "Resume", + "stopDay": "Stop for today", + "stopDayConfirm": "Stop?", + "myTime": "My time", + "offline": "offline" + }, + "switch": { + "title": "Another time recording is running", + "text": "{from} is still running. Pause it and start {to}?", + "textUnknown": "Time is still running on another order. Pause it and start {to}?", + "confirm": "Switch", + "cancel": "Cancel" + }, + "myTime": { + "title": "My time", + "sub": "Your time per day across all orders.", + "today": "Today", + "yesterday": "Yesterday", + "days": "Choose day", + "empty": "No time recorded on this day.", + "approved": "Approved", + "pending": "Awaiting approval", + "dayTotal": "Day total", + "totalApproved": "Approved: {duration}", + "totalPending": "Open: {duration}", + "add": "Add time", + "running": "running", + "badge": { + "manual": "Manual", + "pending": "Awaiting approval", + "rejected": "Rejected: {reason}", + "correctionRequested": "Correction requested", + "correctionRejected": "Correction rejected: {reason}", + "corrected": "Corrected" + }, + "proposed": "Proposal: {from}–{to} · {type}", + "localPending": "Waiting to be sent", + "localTitle": "Not sent yet", + "form": { + "title": "Add time", + "order": "Order", + "orderPlaceholder": "Choose order", + "noOrders": "No orders from the last 7 days available.", + "forUser": "For", + "me": "Myself", + "type": "Type", + "date": "Date", + "mode": "Entry", + "modeRange": "From – to", + "modeDuration": "Duration", + "from": "From", + "to": "To", + "duration": "Duration in minutes", + "quick": "{minutes} min", + "reason": "Reason", + "reasonHint": "Required, at least 3 characters.", + "note": "Note (optional)", + "approvalHint": "Counts once approved by the team lead or office.", + "directHint": "Time recorded for team members is approved immediately.", + "save": "Save entry", + "saved": "Entry saved – awaiting approval.", + "savedDirect": "Time saved.", + "queued": "Saved offline – will be sent once connected.", + "cancel": "Cancel" + }, + "correction": { + "title": "Propose correction", + "hint": "The current time stays valid until approved.", + "save": "Send proposal", + "saved": "Correction requested." + }, + "reasons": { + "forgotStart": "Forgot to start", + "noNetwork": "No network", + "recordedLater": "Recorded afterwards" + }, + "errors": { + "overlap": "The period overlaps with time already recorded.", + "too_old": "Time can only be added for the last 7 days.", + "in_future": "The time must not be in the future.", + "too_long": "An entry may be at most 16 hours long.", + "end_before_start": "The end must be after the start.", + "work_order_status": "Time can no longer be recorded for this order.", + "not_own_entry": "You can only correct your own time.", + "own_entry": "You cannot approve your own time.", + "entry_running": "The time is still running." + } + }, + "approvals": { + "title": "Approve time", + "sub": "Added entries and corrections of your team.", + "empty": "No open entries or corrections.", + "link": "Approve time", + "count": "{count, plural, =0 {Nothing open} one {# open} other {# open}}", + "manual": "Added entry", + "correction": "Correction", + "reason": "Reason", + "note": "Note", + "old": "Before", + "new": "New", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "rejectConfirm": "Send rejection", + "cancel": "Cancel", + "approved": "Approved.", + "rejected": "Rejected." + }, + "pendingTime": { + "report": "Note: {duration} of added time is not approved yet and missing from the report.", + "proposals": "Note: {count, plural, one {# time correction is} other {# time corrections are}} awaiting approval." } } diff --git a/messages/en/nav.json b/messages/en/nav.json index 91f4ba6..5e97423 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -16,5 +16,6 @@ "lotse": "Lotse (AI)", "admin": "Admin console", "openMenu": "Open menu", - "closeMenu": "Close menu" + "closeMenu": "Close menu", + "timeApprovals": "Time approvals" } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 3206fdf..13ca202 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -45,26 +45,92 @@ "emergency_completed": "Emergency call-out completed", "import_ready_for_review": "Import ready for review", "import_failed": "Document processing failed", - "sync_failed": "Sync failed" + "sync_failed": "Sync failed", + "time_approval_requested": "Time to approve", + "time_approved": "Time approved", + "time_rejected": "Time rejected" }, "events": { - "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, - "work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." }, - "work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." }, - "work_order_started": { "title": "Work order {number} started", "message": "{actor} started work at {customer}." }, - "work_order_daily_report_created": { "title": "Daily report for {number}", "message": "{actor} created a daily report for {customer}." }, - "work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." }, - "work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." }, - "work_order_missing_required": { "title": "Required information missing: {number}", "message": "{title} – {customer}: documentation incomplete." }, - "work_order_released_for_billing": { "title": "Work order {number} ready for billing", "message": "{title} – {customer} is ready for billing." }, - "report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." }, - "report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." }, - "report_rejected": { "title": "Report rejected: {number}", "message": "The report for {title} needs a correction." }, - "emergency_created": { "title": "Emergency call-out {number} created", "message": "{actor} created an emergency call-out at {customer}." }, - "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, - "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, - "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." }, - "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." } + "work_order_assigned": { + "title": "New work order {number}", + "message": "{title} – {customer} was assigned to your team." + }, + "work_order_changed": { + "title": "Work order {number} changed", + "message": "{title} – {customer}: details were updated." + }, + "work_order_cancelled": { + "title": "Work order {number} cancelled", + "message": "{title} – {customer} was cancelled." + }, + "work_order_started": { + "title": "Work order {number} started", + "message": "{actor} started work at {customer}." + }, + "work_order_daily_report_created": { + "title": "Daily report for {number}", + "message": "{actor} created a daily report for {customer}." + }, + "work_order_technically_completed": { + "title": "Work order {number} technically completed", + "message": "{title} – {customer}: on-site work finished." + }, + "work_order_signature_missing": { + "title": "Signature missing: {number}", + "message": "{title} – {customer}: no customer signature." + }, + "work_order_missing_required": { + "title": "Required information missing: {number}", + "message": "{title} – {customer}: documentation incomplete." + }, + "work_order_released_for_billing": { + "title": "Work order {number} ready for billing", + "message": "{title} – {customer} is ready for billing." + }, + "report_submitted": { + "title": "Report for review: {number}", + "message": "{actor} submitted a report for {title}." + }, + "report_approved": { + "title": "Report approved: {number}", + "message": "The report for {title} was approved." + }, + "report_rejected": { + "title": "Report rejected: {number}", + "message": "The report for {title} needs a correction." + }, + "emergency_created": { + "title": "Emergency call-out {number} created", + "message": "{actor} created an emergency call-out at {customer}." + }, + "emergency_completed": { + "title": "Emergency call-out {number} completed", + "message": "{customer}: ready for review and billing." + }, + "import_ready_for_review": { + "title": "Import ready for review", + "message": "The data from {fileName} is ready for review." + }, + "import_failed": { + "title": "Document processing failed", + "message": "{fileName} could not be processed." + }, + "sync_failed": { + "title": "Sync failed", + "message": "A change could not be applied ({reason})." + }, + "time_approval_requested": { + "title": "Time to approve: {number}", + "message": "{actor} added time or requested a correction for {number}." + }, + "time_approved": { + "title": "Time approved: {number}", + "message": "{actor} approved your time for {number}." + }, + "time_rejected": { + "title": "Time rejected: {number}", + "message": "{actor} rejected your time for {number}: {reason}" + } }, "fallback": { "unknown": "unknown", diff --git a/messages/en/offline.json b/messages/en/offline.json index 2a982be..b2ec4a9 100644 --- a/messages/en/offline.json +++ b/messages/en/offline.json @@ -89,7 +89,10 @@ "upload": "The file was not accepted (format or size).", "unauthorized": "Your session expired – please sign in again.", "network": "No connection – will retry automatically.", - "internal": "Technical error – will retry automatically." + "internal": "Technical error – will retry automatically.", + "timeOverlap": "The added time overlaps with time already recorded and was not accepted.", + "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.", + "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch." }, "view": { "title": "Offline view", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 166e853..9ff39e2 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -237,7 +237,21 @@ "material_procurement": "Material procurement", "return_travel": "Return travel", "interruption": "Interruption" - } + }, + "pendingMinutes": "Awaiting approval: {minutes} min", + "approvedMinutes": "Approved", + "badgeManual": "Manual", + "badgePending": "Awaiting approval", + "badgeRejected": "Rejected: {reason}", + "badgeCorrection": "Correction requested: {from}–{to}", + "reason": "Reason: {reason}", + "manualSession": "Recorded later", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason", + "approved": "Approved.", + "rejected": "Rejected.", + "openApprovals": "All open approvals" }, "photos": { "empty": "No photos yet.", @@ -460,8 +474,35 @@ "running_session": "A work session is still running", "field": { "signature": "Signature missing", - "approved_completion_report": "Completion report not approved yet" + "approved_completion_report": "Completion report not approved yet", + "pending_time_entries": "Time entries are still awaiting approval" } }, - "saved": "Saved." + "saved": "Saved.", + "timeApprovals": { + "crumb": "Work orders", + "title": "Time approvals", + "sub": "Added time and corrections by technicians. They count for reports and billing only after approval.", + "empty": "No open entries or corrections.", + "employee": "Employee", + "order": "Order", + "date": "Date", + "range": "From–to", + "duration": "Duration", + "type": "Type", + "reason": "Reason", + "kindManual": "Added entry", + "kindCorrection": "Correction", + "oldNew": "{old} → {new}", + "select": "Select", + "selectAll": "Select all", + "approveSelected": "Approve selected", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "approved": "Approved.", + "bulkApproved": "{ok} approved, {failed} not possible.", + "rejected": "Rejected.", + "count": "{count, plural, one {# open entry} other {# open entries}}" + } } diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0261dee..d828066 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -12,6 +12,7 @@ import { Receipt, RefreshCw, Siren, + Timer, Wrench, type LucideIcon, } from "lucide-react"; @@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings"; type SP = Record; -const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [ +const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [ { key: "open", icon: ClipboardList, tone: "var(--ui-primary)" }, { key: "today", icon: CalendarDays, tone: "var(--info)" }, { key: "running", icon: Wrench, tone: "var(--ui-accent)" }, @@ -39,6 +40,7 @@ const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[ { key: "emergency_new", icon: Siren, tone: "var(--risk)" }, { key: "missing_signatures", icon: PenLine, tone: "var(--warn)" }, { key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" }, + { key: "time_approvals", icon: Timer, tone: "var(--warn)" }, ]; /** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */ @@ -142,9 +144,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
    - {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { - const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; - const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; + {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => { + const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; + const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; return (
  • new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { hour: "2-digit", minute: "2-digit", timeZone: tz }).format(d); + const span = (s: Date, e: Date | null) => `${time(s)}–${e ? time(e) : "…"}`; + + return ( +
    + + + {t("detail.back")} + + + + {items.length === 0 ? ( + {t("timeApprovals.empty")} + ) : ( +
    +
    +

    {t("timeApprovals.count", { count: items.length })}

    + +
    +
    + + + + + + + + + + + + + + + {items.map((i) => ( + + + + + + + + + + + + ))} + +
    + + {t("timeApprovals.employee")}{t("timeApprovals.order")}{t("timeApprovals.date")}{t("timeApprovals.range")}{t("timeApprovals.duration")}{t("timeApprovals.type")}{t("timeApprovals.reason")} +
    + + +

    {i.userName}

    +

    {i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}

    +
    + + {i.number} + +

    {i.title}

    +
    {formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)} + {i.proposed ? ( + t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) }) + ) : ( + span(i.startedAt, i.endedAt) + )} + + {i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`} + + {i.proposed && i.proposed.type !== i.type + ? t("timeApprovals.oldNew", { old: tf(`time.type.${i.type}`), new: tf(`time.type.${i.proposed.type}`) }) + : tf(`time.type.${i.proposed?.type ?? i.type}`)} + +

    {i.reason}

    + {i.note &&

    {i.note}

    } +

    {formatDateTime(i.requestedAt, locale, tz)}

    +
    + + + + + + + + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/app/(field)/m/(core)/approvals/page.tsx b/src/app/(field)/m/(core)/approvals/page.tsx new file mode 100644 index 0000000..2be1ca6 --- /dev/null +++ b/src/app/(field)/m/(core)/approvals/page.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { can } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { listPendingTimeEntries } from "@/server/services/field/time-entries"; +import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format"; +import { card } from "@/components/field/ui"; +import { ApprovalActions } from "@/components/field/approval-actions"; + +/** `/m/approvals` — team lead mobile (L12, `time:approve`): open manual entries and correction proposals of the team. */ +export default async function ApprovalsPage() { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const locale = await getLocale(); + if (!can(ctx, "time:approve")) return

    {t("noAccess")}

    ; + const items = await listPendingTimeEntries(ctx); + const range = (s: Date, e: Date | null) => `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${e ? fmtTime(e, locale) : t("myTime.running")}`; + + return ( +
    +
    +

    {t("approvals.title")}

    +

    {t("approvals.sub")}

    +

    {t("approvals.count", { count: items.length })}

    +
    + {items.length === 0 ? ( +

    {t("approvals.empty")}

    + ) : ( +
      + {items.map((i) => ( +
    • +

      {i.kind === "correction" ? t("approvals.correction") : t("approvals.manual")}

      +

      {i.userName}

      + + {i.number} · {i.title} + +
      +
      +
      {i.kind === "correction" ? t("approvals.old") : t("myTime.form.type")}
      +
      + {i.proposed && {t("approvals.old")}: } + {t(`time.type.${i.type}`)} · {range(i.startedAt, i.endedAt)} · {fmtDuration(i.minutes * 60)} +
      +
      + {i.proposed && ( +
      +
      {t("approvals.new")}
      +
      + {t("approvals.new")}: {t(`time.type.${i.proposed.type}`)} · {range(i.proposed.startedAt, i.proposed.endedAt)} · {fmtDuration(i.proposed.minutes * 60)} +
      +
      + )} + {i.reason && ( +
      +
      {t("approvals.reason")}:
      +
      {i.reason}
      +
      + )} + {i.note && ( +
      +
      {t("approvals.note")}:
      +
      {i.note}
      +
      + )} +
      +
      + +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx index 565a21f..988534f 100644 --- a/src/app/(field)/m/(core)/orders/[id]/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx @@ -112,12 +112,15 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length; const checklistDone = order.checklistItems.filter((i) => i.checked).length; const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length; + // L12: only approved entries count const myWorkSeconds = order.workSessions .filter((s) => s.userId === ctx.userId) .flatMap((s) => s.entries) - .filter((e) => e.type === "work") + .filter((e) => e.type === "work" && e.approvalStatus === "approved") .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); const editable = can(ctx, "field:execute"); + const ownActive = order.workSessions.find((s) => s.userId === ctx.userId && !s.manual && s.status !== "ended"); + const segmentType = ownActive?.entries.find((e) => !e.endedAt)?.type ?? null; const quick = [ { href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote }, @@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id

    {editable && (
    - +
    )} diff --git a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx index 9211595..2cd98a7 100644 --- a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx @@ -1,7 +1,13 @@ import { ReportScreen } from "@/components/reports/mobile/report-screen"; +import { PendingTimeNotice } from "@/components/field/pending-time-notice"; -/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */ +/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. L12: warning about unapproved time (not blocking). */ export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) { const [{ id }, sp] = await Promise.all([params, searchParams]); - return ; + return ( + <> + + + + ); } diff --git a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx index e5191b1..2ab6a9b 100644 --- a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx @@ -1,51 +1,75 @@ +import Link from "next/link"; import { getLocale, getTranslations } from "next-intl/server"; +import { Plus } from "lucide-react"; import { can } from "@/server/services/context"; +import { parsePendingChange } from "@/server/services/field/time-entries"; import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format"; import { SubPageHeader } from "@/components/field/sub-page-header"; import { TimeCorrectionForm } from "@/components/field/time-correction-form"; -import { card } from "@/components/field/ui"; +import { CorrectionProposalForm } from "@/components/field/correction-proposal-form"; +import { TimeEntryBadges } from "@/components/field/time-entry-badges"; +import { btnSecondary, card } from "@/components/field/ui"; import { loadOrder } from "../load"; -/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */ +/** + * `/m/orders/[id]/time` — sessions and time segments (Spec §12.2). + * L12: only approved entries count; own entries → „Korrektur vorschlagen" (approval), entries of + * team members → direct correction with `field:correct_time`; badges manual/pending/rejected. + */ export default async function TimePage({ params }: { params: Promise<{ id: string }> }) { const { ctx, order } = await loadOrder(params); const t = await getTranslations("field"); const locale = await getLocale(); const canCorrect = can(ctx, "field:correct_time"); - const totalWork = order.workSessions - .flatMap((s) => s.entries) - .filter((e) => e.type === "work") - .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const canPropose = can(ctx, "field:record_own_time") || canCorrect; + const entries = order.workSessions.flatMap((s) => s.entries); + const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const pendingWork = entries.filter((e) => e.type !== "break" && e.approvalStatus === "pending").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); return (

    {t("time.totalWork", { duration: fmtDuration(totalWork) })}

    + {pendingWork > 0 &&

    {t("time.totalPending", { duration: fmtDuration(pendingWork) })}

    } + {canPropose && ( + + + {t("myTime.add")} + + )} {order.workSessions.length === 0 &&

    {t("time.empty")}

    } {order.workSessions.map((s) => (

    {s.user.name}

    - {t(`time.session.${s.status}`)} + {s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)} {s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}

    {fmtDateTime(s.startedAt, locale)}

      - {s.entries.map((e) => ( -
    • -
      - {t(`time.type.${e.type}`)} - - {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} - -
      - {e.corrected &&

      {t("time.corrected", { reason: e.correctionReason ?? "" })}

      } - {canCorrect && } -
    • - ))} + {s.entries.map((e) => { + const own = e.userId === ctx.userId; + const pendingChange = parsePendingChange(e.pendingChange); + return ( +
    • +
      + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} + +
      + + {e.corrected &&

      {t("time.corrected", { reason: e.correctionReason ?? "" })}

      } + {own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && ( + + )} + {!own && canCorrect && } +
    • + ); + })}
    ))} diff --git a/src/app/(field)/m/(core)/orders/page.tsx b/src/app/(field)/m/(core)/orders/page.tsx index d20248e..f8e6348 100644 --- a/src/app/(field)/m/(core)/orders/page.tsx +++ b/src/app/(field)/m/(core)/orders/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries"; import { OrderCard } from "@/components/field/order-card"; @@ -15,6 +16,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi const { tab: rawTab } = await searchParams; const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running"; const orders = await listFieldOrders(ctx, tab); + const canExecute = can(ctx, "field:execute"); return (
    @@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
      {orders.map((o) => (
    • - +
    • ))}
    diff --git a/src/app/(field)/m/(core)/page.tsx b/src/app/(field)/m/(core)/page.tsx index 0802068..698ca65 100644 --- a/src/app/(field)/m/(core)/page.tsx +++ b/src/app/(field)/m/(core)/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ChevronRight } from "lucide-react"; +import { can } from "@/server/services/context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { listTodayOrders } from "@/server/services/field/queries"; import { OrderCard } from "@/components/field/order-card"; @@ -12,6 +13,7 @@ export default async function TodayPage() { const t = await getTranslations("field"); if (!canUseFieldApp(ctx)) return

    {t("noAccess")}

    ; + const canExecute = can(ctx, "field:execute"); const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]); return ( @@ -27,7 +29,7 @@ export default async function TodayPage() {
      {orders.map((o) => (
    • - +
    • ))}
    diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx index 90ad296..32ad5a1 100644 --- a/src/app/(field)/m/(core)/profile/page.tsx +++ b/src/app/(field)/m/(core)/profile/page.tsx @@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth"; import { prisma } from "@/server/db"; import { can } from "@/server/services/context"; import { activeTeamIds } from "@/server/services/work-orders/visibility"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; import { fieldPageContext } from "@/server/services/field/page-context"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { btnSecondary, card } from "@/components/field/ui"; @@ -15,7 +16,7 @@ export default async function ProfilePage() { const ctx = await fieldPageContext(); const t = await getTranslations("field.profile"); const session = await auth(); - const teamIds = await activeTeamIds(ctx); + const [teamIds, approvals] = await Promise.all([activeTeamIds(ctx), countPendingTimeEntries(ctx)]); const [me, teams, identity] = await Promise.all([ ctx.db.user.findFirst({ where: { id: ctx.userId }, @@ -58,6 +59,17 @@ export default async function ProfilePage() {
    + {can(ctx, "field:execute") && ( + + {t("myTime")} + + )} + {can(ctx, "time:approve") && ( + + {t("approvals")} + {approvals > 0 && {approvals}} + + )} {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && ( {t("backoffice")} diff --git a/src/app/(field)/m/(core)/time/new/page.tsx b/src/app/(field)/m/(core)/time/new/page.tsx new file mode 100644 index 0000000..ac20763 --- /dev/null +++ b/src/app/(field)/m/(core)/time/new/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ChevronLeft } from "lucide-react"; +import { can } from "@/server/services/context"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { getMyTimeOverview, listRecordableOrders, listRecordableUsers } from "@/server/services/field/time-entries"; +import { ManualTimeForm } from "@/components/field/manual-time-form"; + +/** `/m/time/new` — „Zeit nachtragen" (L12). */ +export default async function NewTimePage({ searchParams }: { searchParams: Promise<{ date?: string; order?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time"); + if (!canUseFieldApp(ctx) || !canRecord) return

    {t("noAccess")}

    ; + + const sp = await searchParams; + const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]); + + return ( +
    + + + {t("myTime.title")} + +

    {t("myTime.form.title")}

    + o.id === sp.order) ? sp.order! : null} + /> +
    + ); +} diff --git a/src/app/(field)/m/(core)/time/page.tsx b/src/app/(field)/m/(core)/time/page.tsx new file mode 100644 index 0000000..804c85d --- /dev/null +++ b/src/app/(field)/m/(core)/time/page.tsx @@ -0,0 +1,105 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ChevronRight, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { getMyTimeOverview } from "@/server/services/field/time-entries"; +import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format"; +import { btnPrimary, card, chip } from "@/components/field/ui"; +import { CorrectionProposalForm } from "@/components/field/correction-proposal-form"; +import { LocalPendingTimes } from "@/components/field/local-pending-times"; +import { TimeEntryBadges } from "@/components/field/time-entry-badges"; + +/** `/m/time` — „Meine Zeiten" (L12): own entries of a day across all orders, approved vs. pending totals. */ +export default async function MyTimePage({ searchParams }: { searchParams: Promise<{ date?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const locale = await getLocale(); + if (!canUseFieldApp(ctx) || !can(ctx, "field:execute")) return

    {t("noAccess")}

    ; + + const { date } = await searchParams; + const overview = await getMyTimeOverview(ctx, { date }); + const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time"); + const dayLabel = (key: string, i: number) => (i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${key}T12:00:00Z`, locale)); + + return ( +
    +
    +

    {t("myTime.title")}

    +

    {t("myTime.sub")}

    +
    + + + +
    +
    +

    {t("myTime.approved")}

    +

    {fmtDuration(overview.approvedSeconds)}

    +
    +
    +

    {t("myTime.pending")}

    +

    0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}

    +
    +
    + + {canRecord && ( + + + {t("myTime.add")} + + )} + + + + {overview.groups.length === 0 ? ( +

    {t("myTime.empty")}

    + ) : ( + overview.groups.map((g) => ( +
    + + + {g.number} + {g.title} + + + +

    + {t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })} + {g.pendingSeconds > 0 && · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}} +

    +
      + {g.entries.map((e) => ( +
    • +
      + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("myTime.running")} · {fmtDuration(e.seconds)} + +
      + + {canRecord && e.editable && ( + + )} +
    • + ))} +
    +
    + )) + )} +
    + ); +} diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index 5c7619b..ca6f601 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -1,21 +1,45 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { requireAppAccess } from "@/server/app-access"; +import { can } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { getMyActiveSession } from "@/server/services/field/sessions"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; +import { cn } from "@/lib/utils"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { BottomNav } from "@/components/field/bottom-nav"; import { OnlineBadge } from "@/components/field/online-badge"; +import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar"; import { OfflineRuntime } from "@/components/offline/offline-runtime"; +/** L12: own running/paused session + open approvals for the shell (never blocks the page). */ +async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> { + try { + const ctx = await fieldPageContext(); + const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]); + return { + clock: session + ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } + : null, + approvals, + }; + } catch { + // module "field" disabled or no field permissions: shell without clock/badge + return { clock: null, approvals: 0 }; + } +} + /** * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice * (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge. * Module gates live one level below: (core) → "field", emergency → "emergency". + * L12: running clock bar above the bottom navigation on every /m page. */ export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) { const access = await requireAppAccess(); if (access.kind === "inactive") return ; - const t = await getTranslations("field.nav"); + const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]); return (
    @@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
    -
    {children}
    - +
    {children}
    + + ); } diff --git a/src/components/field/approval-actions.tsx b/src/components/field/approval-actions.tsx new file mode 100644 index 0000000..63029bb --- /dev/null +++ b/src/components/field/approval-actions.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Check, TriangleAlert, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { approveTime, rejectTime } from "@/server/actions/field/time"; +import { timeErrorKey } from "@/lib/field/time-rules"; +import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui"; + +/** L12 team lead mobile: approve / reject (reason mandatory) with large buttons. */ +export function ApprovalActions({ timeEntryId }: { timeEntryId: string }) { + const t = useTranslations("field"); + const router = useRouter(); + const [rejecting, setRejecting] = useState(false); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run(kind: "approve" | "reject") { + setError(null); + if (kind === "reject" && reason.trim().length < 3) { + setError(t("myTime.form.reasonHint")); + return; + } + setBusy(true); + const result = await (kind === "approve" ? approveTime({ timeEntryId }) : rejectTime({ timeEntryId, reason: reason.trim() })).catch(() => ({ ok: false as const, error: "failed" as const, message: undefined })); + setBusy(false); + if (!result.ok) { + const key = timeErrorKey({ message: result.message }); + setError(key ? t(`myTime.errors.${key}`) : t(`errors.${result.error === "failed" ? "internal" : result.error}`)); + return; + } + router.refresh(); + } + + return ( +
    + {!rejecting ? ( +
    + + +
    + ) : ( +
    +