diff --git a/messages/de/field.json b/messages/de/field.json
new file mode 100644
index 0000000..ba53ccf
--- /dev/null
+++ b/messages/de/field.json
@@ -0,0 +1,298 @@
+{
+ "nav": {
+ "label": "Einsatz-Navigation",
+ "today": "Heute",
+ "orders": "Aufträge",
+ "emergency": "Notdienst",
+ "sync": "Sync",
+ "profile": "Profil"
+ },
+ "connection": {
+ "online": "Online",
+ "offline": "Offline"
+ },
+ "noAccess": "Die Einsatz-App ist für Ihre Rolle nicht freigeschaltet.",
+ "today": {
+ "title": "Heute",
+ "greeting": "Hallo {name}",
+ "count": "{count, plural, =0 {Keine Einsätze} one {# Einsatz} other {# Einsätze}}",
+ "empty": "Heute stehen keine Einsätze an.",
+ "allOrders": "Alle Aufträge ansehen"
+ },
+ "orders": {
+ "title": "Aufträge",
+ "tabs": {
+ "upcoming": "Kommend",
+ "running": "Laufend",
+ "to_complete": "Abzuschließen",
+ "past": "Vergangen"
+ },
+ "empty": "Keine Aufträge in dieser Liste."
+ },
+ "card": {
+ "open": "Auftrag öffnen",
+ "route": "Route",
+ "noDate": "Ohne Termin",
+ "emergency": "Notdienst",
+ "urgent": "Dringend",
+ "high": "Hoch"
+ },
+ "statusGroup": {
+ "new": "Neu",
+ "planned": "Geplant",
+ "en_route": "Unterwegs",
+ "in_progress": "In Arbeit",
+ "documentation_incomplete": "Dokumentation unvollständig",
+ "in_review": "Zur Prüfung",
+ "ready_for_billing": "Bereit zur Abrechnung",
+ "billed": "Abgerechnet",
+ "cancelled": "Storniert"
+ },
+ "statusDetail": {
+ "draft": "Entwurf",
+ "review_required": "Prüfung erforderlich",
+ "planned": "Geplant",
+ "assigned": "Zugewiesen",
+ "accepted": "Angenommen",
+ "en_route": "In Anfahrt",
+ "in_progress": "In Arbeit",
+ "paused": "Pausiert",
+ "waiting_material": "Wartet auf Material",
+ "daily_report_created": "Tagesbericht erstellt",
+ "technically_completed": "Technisch abgeschlossen",
+ "signature_pending": "Unterschrift ausstehend",
+ "in_review": "Zur Prüfung",
+ "released_for_billing": "Bereit zur Abrechnung",
+ "billed": "Abgerechnet",
+ "cancelled": "Storniert"
+ },
+ "action": {
+ "accept": "Annehmen",
+ "travel": "Losfahren",
+ "start": "Arbeit starten",
+ "pause": "Pause",
+ "resume": "Weiter",
+ "complete": "Abschließen",
+ "confirmComplete": "Jetzt abschließen",
+ "confirmHint": "Zeiterfassung wird beendet und der Auftrag technisch abgeschlossen.",
+ "saving": "Wird gespeichert …",
+ "blocked": "Vor dem Abschluss noch offen:",
+ "reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.",
+ "nothing": "Keine Aktion erforderlich."
+ },
+ "blocker": {
+ "checklist_item": "Checkliste: {label}",
+ "photo_requirement": "Pflichtfoto: {label}",
+ "running_session": "Zeiterfassung eines Teammitglieds läuft noch",
+ "missing_field": "Angabe fehlt: {label}"
+ },
+ "errors": {
+ "not_found": "Nicht gefunden oder nicht freigegeben.",
+ "forbidden": "Keine Berechtigung für diese Aktion.",
+ "invalid": "Eingabe prüfen.",
+ "conflict": "Der Auftrag wurde inzwischen geändert. Die Ansicht ist aktualisiert.",
+ "blocked": "Pflichtangaben fehlen noch.",
+ "internal": "Technischer Fehler. Bitte erneut versuchen.",
+ "network": "Keine Verbindung. Bitte erneut versuchen.",
+ "image": "Foto konnte nicht verarbeitet werden."
+ },
+ "detail": {
+ "back": "Zurück",
+ "backToOrder": "Zum Auftrag",
+ "window": "Zeitfenster",
+ "hints": "Hinweise für Monteure",
+ "order": "Auftrag",
+ "orderType": "Auftragsart",
+ "externalNumber": "Kundenauftrag",
+ "team": "Team",
+ "scope": "Leistungsumfang",
+ "description": "Beschreibung",
+ "customer": "Kunde",
+ "contact": "Ansprechpartner",
+ "onSiteContact": "Vor Ort",
+ "call": "Anrufen",
+ "mail": "E-Mail",
+ "site": "Objekt",
+ "route": "Route öffnen",
+ "access": "Zugang",
+ "parking": "Parken",
+ "safety": "Sicherheit",
+ "technical": "Technik",
+ "documents": "Dokumente",
+ "noDocuments": "Keine Dokumente.",
+ "siteDocument": "Objekt",
+ "openDocument": "Öffnen",
+ "history": "Objekt-Historie",
+ "noHistory": "Keine freigegebenen Berichte am Objekt.",
+ "reportType": {
+ "daily": "Tagesbericht",
+ "completion": "Abschlussbericht"
+ },
+ "checklist": "Checkliste",
+ "checklistProgress": "{done} von {total} erledigt",
+ "materials": "Material",
+ "materialsProgress": "{done} von {total} Positionen erfasst",
+ "materialsNone": "Kein Material geplant",
+ "photos": "Fotos",
+ "photosProgress": "{count} Fotos · {done} von {total} Pflichtfotos",
+ "photosCount": "{count} Fotos",
+ "notes": "Notizen",
+ "notesCount": "{count, plural, =0 {Noch keine Notizen} one {# Notiz} other {# Notizen}}",
+ "time": "Zeiten",
+ "myTime": "Meine Arbeitszeit: {duration}",
+ "quick": {
+ "photo": "Foto",
+ "note": "Notiz",
+ "material": "Material",
+ "checklist": "Checkliste",
+ "time": "Zeiten"
+ }
+ },
+ "photos": {
+ "title": "Fotos",
+ "camera": "Kamera",
+ "gallery": "Galerie",
+ "phaseLabel": "Zeitpunkt",
+ "phase": {
+ "before": "Vorher",
+ "during": "Während",
+ "after": "Nachher"
+ },
+ "requirement": "Pflichtfoto",
+ "checklistItem": "Checklistenpunkt",
+ "none": "Keine Zuordnung",
+ "comment": "Kommentar",
+ "location": "Standort mitsenden",
+ "save": "Foto speichern",
+ "discard": "Verwerfen",
+ "preview": "Vorschau des Fotos",
+ "compressing": "Foto wird verkleinert …",
+ "uploading": "Hochladen … {percent} %",
+ "saved": "Foto gespeichert.",
+ "empty": "Noch keine Fotos.",
+ "requirements": "Pflichtfotos",
+ "missing": "Fehlt",
+ "done": "Vorhanden"
+ },
+ "notes": {
+ "title": "Notizen",
+ "kindLabel": "Art",
+ "kind": {
+ "work_done": "Durchgeführt",
+ "deviation": "Abweichung",
+ "problem": "Problem",
+ "additional_work": "Zusatzleistung",
+ "not_executable": "Nicht ausführbar",
+ "follow_up": "Folgearbeit",
+ "recommendation": "Empfehlung",
+ "customer_note": "Kundenhinweis",
+ "general": "Allgemein"
+ },
+ "text": "Notiz",
+ "placeholder": "Kurz beschreiben, was vor Ort passiert ist …",
+ "save": "Notiz speichern",
+ "saved": "Notiz gespeichert.",
+ "draftKept": "Entwurf bleibt auf diesem Gerät erhalten.",
+ "empty": "Noch keine Notizen."
+ },
+ "voice": {
+ "title": "Sprachnotiz",
+ "record": "Aufnahme starten",
+ "stop": "Aufnahme beenden",
+ "recording": "Aufnahme läuft · {time} von 5:00",
+ "save": "Sprachnotiz speichern",
+ "discard": "Verwerfen",
+ "saved": "Sprachnotiz gespeichert.",
+ "unsupported": "Sprachaufnahme wird auf diesem Gerät nicht unterstützt.",
+ "denied": "Mikrofon ist nicht freigegeben.",
+ "status": {
+ "pending": "Transkription läuft",
+ "done": "Transkribiert",
+ "failed": "Transkription fehlgeschlagen",
+ "disabled": "Ohne Transkription"
+ }
+ },
+ "materials": {
+ "title": "Material",
+ "planned": "Geplant: {quantity} {unit}",
+ "usage": {
+ "fully_used": "Vollständig",
+ "partially_used": "Teilweise",
+ "not_used": "Nicht verwendet",
+ "additional": "Zusätzlich"
+ },
+ "quantity": "Menge",
+ "unit": "Einheit",
+ "decrease": "Menge verringern",
+ "increase": "Menge erhöhen",
+ "reason": "Begründung der Abweichung",
+ "reasonRequired": "Bei Abweichung ist eine Begründung Pflicht.",
+ "save": "Speichern",
+ "saved": "Gespeichert.",
+ "recorded": "Erfasst: {quantity} {unit}",
+ "open": "Noch offen",
+ "noPlan": "Kein Material geplant.",
+ "additionalTitle": "Zusatzmaterial",
+ "additionalEmpty": "Kein Zusatzmaterial erfasst.",
+ "name": "Bezeichnung",
+ "articleNumber": "Artikelnummer (optional)",
+ "additionalReason": "Grund",
+ "add": "Zusatzmaterial speichern",
+ "invalid": "Angaben unvollständig oder Menge passt nicht zum Status."
+ },
+ "checklist": {
+ "title": "Checkliste",
+ "required": "Pflicht",
+ "photo": "Foto nötig",
+ "done": "Erledigt",
+ "open": "Offen",
+ "comment": "Kommentar",
+ "saveComment": "Kommentar speichern",
+ "empty": "Für diesen Auftrag gibt es keine Checkliste."
+ },
+ "time": {
+ "title": "Zeiten",
+ "type": {
+ "travel": "Anfahrt",
+ "work": "Arbeitszeit",
+ "break": "Pause",
+ "material_procurement": "Materialbeschaffung",
+ "return_travel": "Rückfahrt",
+ "interruption": "Unterbrechung"
+ },
+ "session": {
+ "en_route": "In Anfahrt",
+ "running": "Läuft",
+ "paused": "Pausiert",
+ "ended": "Beendet"
+ },
+ "running": "läuft",
+ "offlineStarted": "offline gestartet",
+ "corrected": "Korrigiert: {reason}",
+ "correct": "Korrigieren",
+ "start": "Beginn",
+ "end": "Ende",
+ "reason": "Grund der Korrektur",
+ "reasonHint": "Pflichtangabe, wird protokolliert.",
+ "save": "Korrektur speichern",
+ "saved": "Korrektur gespeichert.",
+ "empty": "Noch keine Zeiten erfasst.",
+ "totalWork": "Arbeitszeit gesamt: {duration}"
+ },
+ "profile": {
+ "title": "Profil",
+ "tenant": "Betrieb",
+ "teams": "Team",
+ "noTeam": "Keinem Team zugeordnet",
+ "roles": "Rolle",
+ "language": "Sprache",
+ "logout": "Abmelden",
+ "backoffice": "Zum Büro-Bereich"
+ },
+ "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"
+ }
+}
diff --git a/messages/en/field.json b/messages/en/field.json
new file mode 100644
index 0000000..d8a5456
--- /dev/null
+++ b/messages/en/field.json
@@ -0,0 +1,298 @@
+{
+ "nav": {
+ "label": "Field navigation",
+ "today": "Today",
+ "orders": "Orders",
+ "emergency": "Emergency",
+ "sync": "Sync",
+ "profile": "Profile"
+ },
+ "connection": {
+ "online": "Online",
+ "offline": "Offline"
+ },
+ "noAccess": "The field app is not enabled for your role.",
+ "today": {
+ "title": "Today",
+ "greeting": "Hello {name}",
+ "count": "{count, plural, =0 {No jobs} one {# job} other {# jobs}}",
+ "empty": "No jobs scheduled for today.",
+ "allOrders": "View all orders"
+ },
+ "orders": {
+ "title": "Orders",
+ "tabs": {
+ "upcoming": "Upcoming",
+ "running": "Ongoing",
+ "to_complete": "To complete",
+ "past": "Past"
+ },
+ "empty": "No orders in this list."
+ },
+ "card": {
+ "open": "Open order",
+ "route": "Route",
+ "noDate": "No date",
+ "emergency": "Emergency",
+ "urgent": "Urgent",
+ "high": "High"
+ },
+ "statusGroup": {
+ "new": "New",
+ "planned": "Planned",
+ "en_route": "On the way",
+ "in_progress": "In progress",
+ "documentation_incomplete": "Documentation incomplete",
+ "in_review": "In review",
+ "ready_for_billing": "Ready for billing",
+ "billed": "Billed",
+ "cancelled": "Cancelled"
+ },
+ "statusDetail": {
+ "draft": "Draft",
+ "review_required": "Review required",
+ "planned": "Planned",
+ "assigned": "Assigned",
+ "accepted": "Accepted",
+ "en_route": "On the way",
+ "in_progress": "In progress",
+ "paused": "Paused",
+ "waiting_material": "Waiting for material",
+ "daily_report_created": "Daily report created",
+ "technically_completed": "Technically completed",
+ "signature_pending": "Signature pending",
+ "in_review": "In review",
+ "released_for_billing": "Ready for billing",
+ "billed": "Billed",
+ "cancelled": "Cancelled"
+ },
+ "action": {
+ "accept": "Accept",
+ "travel": "Leave now",
+ "start": "Start work",
+ "pause": "Pause",
+ "resume": "Resume",
+ "complete": "Complete",
+ "confirmComplete": "Complete now",
+ "confirmHint": "Time tracking stops and the order is marked technically completed.",
+ "saving": "Saving …",
+ "blocked": "Still missing before completion:",
+ "reportNext": "Report and signature follow in the next step.",
+ "nothing": "No action required."
+ },
+ "blocker": {
+ "checklist_item": "Checklist: {label}",
+ "photo_requirement": "Required photo: {label}",
+ "running_session": "A team member's time tracking is still running",
+ "missing_field": "Missing: {label}"
+ },
+ "errors": {
+ "not_found": "Not found or not shared with you.",
+ "forbidden": "You are not allowed to do this.",
+ "invalid": "Please check your input.",
+ "conflict": "The order was changed in the meantime. The view has been refreshed.",
+ "blocked": "Required information is still missing.",
+ "internal": "Technical error. Please try again.",
+ "network": "No connection. Please try again.",
+ "image": "The photo could not be processed."
+ },
+ "detail": {
+ "back": "Back",
+ "backToOrder": "Back to order",
+ "window": "Time window",
+ "hints": "Notes for technicians",
+ "order": "Order",
+ "orderType": "Order type",
+ "externalNumber": "Customer order",
+ "team": "Team",
+ "scope": "Scope of work",
+ "description": "Description",
+ "customer": "Customer",
+ "contact": "Contact",
+ "onSiteContact": "On site",
+ "call": "Call",
+ "mail": "Email",
+ "site": "Site",
+ "route": "Open route",
+ "access": "Access",
+ "parking": "Parking",
+ "safety": "Safety",
+ "technical": "Technical",
+ "documents": "Documents",
+ "noDocuments": "No documents.",
+ "siteDocument": "Site",
+ "openDocument": "Open",
+ "history": "Site history",
+ "noHistory": "No approved reports at this site.",
+ "reportType": {
+ "daily": "Daily report",
+ "completion": "Completion report"
+ },
+ "checklist": "Checklist",
+ "checklistProgress": "{done} of {total} done",
+ "materials": "Material",
+ "materialsProgress": "{done} of {total} items recorded",
+ "materialsNone": "No material planned",
+ "photos": "Photos",
+ "photosProgress": "{count} photos · {done} of {total} required photos",
+ "photosCount": "{count} photos",
+ "notes": "Notes",
+ "notesCount": "{count, plural, =0 {No notes yet} one {# note} other {# notes}}",
+ "time": "Time",
+ "myTime": "My working time: {duration}",
+ "quick": {
+ "photo": "Photo",
+ "note": "Note",
+ "material": "Material",
+ "checklist": "Checklist",
+ "time": "Time"
+ }
+ },
+ "photos": {
+ "title": "Photos",
+ "camera": "Camera",
+ "gallery": "Gallery",
+ "phaseLabel": "When",
+ "phase": {
+ "before": "Before",
+ "during": "During",
+ "after": "After"
+ },
+ "requirement": "Required photo",
+ "checklistItem": "Checklist item",
+ "none": "Not assigned",
+ "comment": "Comment",
+ "location": "Include location",
+ "save": "Save photo",
+ "discard": "Discard",
+ "preview": "Photo preview",
+ "compressing": "Resizing photo …",
+ "uploading": "Uploading … {percent} %",
+ "saved": "Photo saved.",
+ "empty": "No photos yet.",
+ "requirements": "Required photos",
+ "missing": "Missing",
+ "done": "Present"
+ },
+ "notes": {
+ "title": "Notes",
+ "kindLabel": "Type",
+ "kind": {
+ "work_done": "Work done",
+ "deviation": "Deviation",
+ "problem": "Problem",
+ "additional_work": "Additional work",
+ "not_executable": "Not feasible",
+ "follow_up": "Follow-up work",
+ "recommendation": "Recommendation",
+ "customer_note": "Customer note",
+ "general": "General"
+ },
+ "text": "Note",
+ "placeholder": "Briefly describe what happened on site …",
+ "save": "Save note",
+ "saved": "Note saved.",
+ "draftKept": "Draft is kept on this device.",
+ "empty": "No notes yet."
+ },
+ "voice": {
+ "title": "Voice note",
+ "record": "Start recording",
+ "stop": "Stop recording",
+ "recording": "Recording · {time} of 5:00",
+ "save": "Save voice note",
+ "discard": "Discard",
+ "saved": "Voice note saved.",
+ "unsupported": "Voice recording is not supported on this device.",
+ "denied": "Microphone access is not granted.",
+ "status": {
+ "pending": "Transcription running",
+ "done": "Transcribed",
+ "failed": "Transcription failed",
+ "disabled": "No transcription"
+ }
+ },
+ "materials": {
+ "title": "Material",
+ "planned": "Planned: {quantity} {unit}",
+ "usage": {
+ "fully_used": "Fully used",
+ "partially_used": "Partially used",
+ "not_used": "Not used",
+ "additional": "Additional"
+ },
+ "quantity": "Quantity",
+ "unit": "Unit",
+ "decrease": "Decrease quantity",
+ "increase": "Increase quantity",
+ "reason": "Reason for deviation",
+ "reasonRequired": "A reason is required for deviations.",
+ "save": "Save",
+ "saved": "Saved.",
+ "recorded": "Recorded: {quantity} {unit}",
+ "open": "Open",
+ "noPlan": "No material planned.",
+ "additionalTitle": "Additional material",
+ "additionalEmpty": "No additional material recorded.",
+ "name": "Name",
+ "articleNumber": "Article number (optional)",
+ "additionalReason": "Reason",
+ "add": "Save additional material",
+ "invalid": "Incomplete input or quantity does not match the status."
+ },
+ "checklist": {
+ "title": "Checklist",
+ "required": "Required",
+ "photo": "Photo needed",
+ "done": "Done",
+ "open": "Open",
+ "comment": "Comment",
+ "saveComment": "Save comment",
+ "empty": "This order has no checklist."
+ },
+ "time": {
+ "title": "Time",
+ "type": {
+ "travel": "Travel",
+ "work": "Working time",
+ "break": "Break",
+ "material_procurement": "Material procurement",
+ "return_travel": "Return travel",
+ "interruption": "Interruption"
+ },
+ "session": {
+ "en_route": "On the way",
+ "running": "Running",
+ "paused": "Paused",
+ "ended": "Ended"
+ },
+ "running": "running",
+ "offlineStarted": "started offline",
+ "corrected": "Corrected: {reason}",
+ "correct": "Correct",
+ "start": "Start",
+ "end": "End",
+ "reason": "Reason for correction",
+ "reasonHint": "Required, will be logged.",
+ "save": "Save correction",
+ "saved": "Correction saved.",
+ "empty": "No time recorded yet.",
+ "totalWork": "Total working time: {duration}"
+ },
+ "profile": {
+ "title": "Profile",
+ "tenant": "Company",
+ "teams": "Team",
+ "noTeam": "Not assigned to a team",
+ "roles": "Role",
+ "language": "Language",
+ "logout": "Sign out",
+ "backoffice": "Go to office area"
+ },
+ "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"
+ }
+}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 9b5527e..ae6edfa 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -1,10 +1,8 @@
import Link from "next/link";
-import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
-import { auth, signOut } from "@/server/auth";
-import { dbForTenant, prisma } from "@/server/db";
-import { isTokenStillValid } from "@/server/sessions";
-import { resolveMfaRequired } from "@/lib/mfa-policy";
+import { signOut } from "@/server/auth";
+import { dbForTenant } from "@/server/db";
+import { requireAppAccess } from "@/server/app-access";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button";
@@ -12,61 +10,16 @@ import { NavLink } from "@/components/nav-link";
import { TenantBrand } from "@/components/brand/tenant-brand";
import { TenantSwitcher } from "@/components/tenant-switcher";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
-import { CraftviaLogo } from "@/components/brand/craftvia-logo";
+import { AccountInactiveNotice } from "@/components/account-inactive-notice";
export default async function AppLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
- const session = await auth();
- if (!session?.user) redirect("/login");
- // Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) →
- // Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
- if (!session.user.tenantId) redirect("/select-tenant");
-
- // Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
- // abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
- // User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
- const account = await dbForTenant(session.user.tenantId).user.findUnique({
- where: { id: session.user.id },
- select: { status: true },
- });
- const identity = session.user.identityId
- ? await prisma.identity.findUnique({
- where: { id: session.user.identityId },
- select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
- })
- : null;
- // SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
- if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
- await signOut({ redirectTo: "/login" });
- }
- if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
- return (
-
-
-
-
Konto deaktiviert
-
Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.
-
-
-
- );
- }
- if (identity.mustChangePassword) redirect("/change-password");
-
- // SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
- // noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb dieses Layouts).
- if (!identity.mfaEnrolledAt) {
- const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
- if (resolveMfaRequired(settings?.securityPolicy)) {
- const passkeys = session.user.identityId
- ? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
- : 0;
- if (passkeys === 0) redirect("/enroll-mfa");
- }
- }
+ // Session-, Konto-, Passwort- und MFA-Prüfungen: gemeinsam mit der Mobile-Shell
+ // (src/server/app-access.ts).
+ const access = await requireAppAccess();
+ if (access.kind === "inactive") return ;
+ const { session, identity } = access;
const t = await getTranslations("nav");
const tc = await getTranslations("common");
diff --git a/src/app/(app)/m/(field)/page.tsx b/src/app/(app)/m/(field)/page.tsx
deleted file mode 100644
index 1281447..0000000
--- a/src/app/(app)/m/(field)/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { ModulePlaceholder } from "@/components/module-placeholder";
-
-export default function Page() {
- return ;
-}
diff --git a/src/app/(app)/m/(field)/layout.tsx b/src/app/(field)/m/(core)/layout.tsx
similarity index 100%
rename from src/app/(app)/m/(field)/layout.tsx
rename to src/app/(field)/m/(core)/layout.tsx
diff --git a/src/app/(field)/m/(core)/orders/[id]/checklist/page.tsx b/src/app/(field)/m/(core)/orders/[id]/checklist/page.tsx
new file mode 100644
index 0000000..e668c5d
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/checklist/page.tsx
@@ -0,0 +1,31 @@
+import { getTranslations } from "next-intl/server";
+import { ChecklistItemRow } from "@/components/field/checklist-item";
+import { SubPageHeader } from "@/components/field/sub-page-header";
+import { loadOrder } from "../load";
+
+/** `/m/orders/[id]/checklist` — checklist execution (Spec §12.4). */
+export default async function ChecklistPage({ params }: { params: Promise<{ id: string }> }) {
+ const { order } = await loadOrder(params);
+ const t = await getTranslations("field");
+ const done = order.checklistItems.filter((i) => i.checked).length;
+
+ return (
+
+
+
+ {order.checklistItems.length === 0 ? (
+
{t("checklist.empty")}
+ ) : (
+ <>
+
{t("detail.checklistProgress", { done, total: order.checklistItems.length })}
+
+ {order.checklistItems.map((item) => (
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/load.ts b/src/app/(field)/m/(core)/orders/[id]/load.ts
new file mode 100644
index 0000000..2704a85
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/load.ts
@@ -0,0 +1,16 @@
+import { notFound } from "next/navigation";
+import { ServiceError } from "@/server/services/context";
+import { fieldPageContext } from "@/server/services/field/page-context";
+import { getFieldOrderDetail } from "@/server/services/field/queries";
+
+/** Shared loader of the order detail and its sub pages: scope violations render 404 (no existence leak). */
+export async function loadOrder(params: Promise<{ id: string }>) {
+ const { id } = await params;
+ const ctx = await fieldPageContext();
+ try {
+ return { ctx, order: await getFieldOrderDetail(ctx, id) };
+ } catch (err) {
+ if (err instanceof ServiceError && err.code === "not_found") notFound();
+ throw err;
+ }
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/materials/page.tsx b/src/app/(field)/m/(core)/orders/[id]/materials/page.tsx
new file mode 100644
index 0000000..6c55edb
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/materials/page.tsx
@@ -0,0 +1,53 @@
+import { getTranslations } from "next-intl/server";
+import { AdditionalMaterialForm, MaterialPlanItem } from "@/components/field/material-forms";
+import { SubPageHeader } from "@/components/field/sub-page-header";
+import { card } from "@/components/field/ui";
+import { loadOrder } from "../load";
+
+/** `/m/orders/[id]/materials` — confirm planned material, record additional material (Spec §13.2/§13.3). */
+export default async function MaterialsPage({ params }: { params: Promise<{ id: string }> }) {
+ const { order } = await loadOrder(params);
+ const t = await getTranslations("field");
+ const additional = order.materialUsages.filter((u) => !u.materialPlanId);
+
+ return (
+
+
+
+ {order.materialPlans.length === 0 ? (
+
{t("materials.noPlan")}
+ ) : (
+
+ {order.materialPlans.map((p) => {
+ const usage = order.materialUsages.find((u) => u.materialPlanId === p.id);
+ return (
+
+ );
+ })}
+
+ )}
+
+
{t("materials.additionalTitle")}
+ {additional.length === 0 ? (
+
{t("materials.additionalEmpty")}
+ ) : (
+
+ {additional.map((u) => (
+
+ {u.name}
+ {t("materials.recorded", { quantity: String(Number(u.actualQuantity)).replace(".", ","), unit: u.unit })}
+ {u.deviationReason && {u.deviationReason}
}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx
new file mode 100644
index 0000000..37f1c87
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx
@@ -0,0 +1,60 @@
+import { getLocale, getTranslations } from "next-intl/server";
+import { fmtDateTime, fmtDuration } from "@/lib/field/format";
+import { NoteForm } from "@/components/field/note-form";
+import { SubPageHeader } from "@/components/field/sub-page-header";
+import { VoiceRecorder } from "@/components/field/voice-recorder";
+import { card } from "@/components/field/ui";
+import { loadOrder } from "../load";
+
+/** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */
+export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) {
+ const { order } = await loadOrder(params);
+ const t = await getTranslations("field");
+ const locale = await getLocale();
+
+ return (
+
+
+
+
+
+
+ {order.voiceNotes.length > 0 && (
+
+ {order.voiceNotes.map((v) => (
+
+
+ {t("voice.title")} · {fmtDateTime(v.recordedAt, locale)}
+ {v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}
+
+
+ {t(`voice.status.${v.transcriptionStatus}`)}
+ {v.transcript && {v.transcript}
}
+
+ ))}
+
+ )}
+
+ {order.notes.length === 0 ? (
+
{t("notes.empty")}
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx
new file mode 100644
index 0000000..f8d60a5
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx
@@ -0,0 +1,312 @@
+import Link from "next/link";
+import { getLocale, getTranslations } from "next-intl/server";
+import {
+ Building2,
+ ChevronLeft,
+ ChevronRight,
+ Clock,
+ FileText,
+ History,
+ Info,
+ KeyRound,
+ ListChecks,
+ Mail,
+ Navigation,
+ Package,
+ Phone,
+ ShieldAlert,
+ SquareParking,
+ StickyNote,
+ User,
+ Wrench,
+ type LucideIcon,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { can } from "@/server/services/context";
+import { customerDisplayName, formatAddress, mapsUrl } from "@/server/services/field/queries";
+import { fmtDate, fmtDuration, fmtWindow, secondsBetween } from "@/lib/field/format";
+import { STATUS_GROUP } from "@/lib/work-orders/status";
+import { PrimaryAction } from "@/components/field/primary-action";
+import { QuickPhotoButton } from "@/components/field/photo-capture";
+import { StatusBadge } from "@/components/field/status-badge";
+import { card, toneClasses } from "@/components/field/ui";
+import { loadOrder } from "./load";
+
+function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
+ const head = (
+
+
+
+
{title}
+ {summary &&
{summary}
}
+
+ {href &&
}
+
+ );
+ return (
+
+ {href ? (
+
+ {head}
+
+ ) : (
+ head
+ )}
+ {children && {children}
}
+
+ );
+}
+
+function ContactButtons({ phone, email, labels }: { phone?: string | null; email?: string | null; labels: { call: string; mail: string } }) {
+ if (!phone && !email) return null;
+ const cls = "inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary";
+ return (
+
+ );
+}
+
+function Notice({ icon: Icon, label, text, tone }: { icon: LucideIcon; label: string; text: string; tone: "warn" | "risk" | "info" }) {
+ const toneCls = {
+ warn: "bg-[color-mix(in_oklch,var(--warn)_12%,transparent)]",
+ risk: "bg-[color-mix(in_oklch,var(--risk)_10%,transparent)]",
+ info: "bg-[color-mix(in_oklch,var(--info)_10%,transparent)]",
+ }[tone];
+ return (
+
+
+
+ {label}
+ {text}
+
+
+ );
+}
+
+/** `/m/orders/[id]` — order detail with status, one primary action and all field sections (Spec §22, US-005/006). */
+export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { ctx, order } = await loadOrder(params);
+ const t = await getTranslations("field");
+ const locale = await getLocale();
+ const base = `/m/orders/${order.id}`;
+
+ const address = formatAddress(order.site) ?? formatAddress(order.customer);
+ const route = mapsUrl(address);
+ const contact = order.contact ?? order.site?.contact ?? null;
+ const blockers = order.blockers
+ .filter((b) => !(b.kind === "running_session" && b.userId === ctx.userId))
+ .map((b) => t(`blocker.${b.kind}`, { label: "label" in b ? b.label : "field" in b ? b.field : "" }));
+ 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;
+ const myWorkSeconds = order.workSessions
+ .filter((s) => s.userId === ctx.userId)
+ .flatMap((s) => s.entries)
+ .filter((e) => e.type === "work")
+ .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
+ const editable = can(ctx, "field:execute");
+
+ const quick = [
+ { href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
+ { href: `${base}/materials`, label: t("detail.quick.material"), icon: Package },
+ { href: `${base}/checklist`, label: t("detail.quick.checklist"), icon: ListChecks },
+ { href: `${base}/time`, label: t("detail.quick.time"), icon: Clock },
+ ];
+
+ return (
+
+
+
+ {t("detail.back")}
+
+
+
+
+ {order.number}
+
+
+ {order.title}
+ {customerDisplayName(order.customer)}
+
+
+ {fmtWindow(order.plannedStart, order.plannedEnd, locale) ?? t("card.noDate")}
+
+ {editable && (
+
+ )}
+
+
+ {editable && (
+
+
+ {quick.map((q) => (
+
+
+ {q.label}
+
+ ))}
+
+ )}
+
+ {order.technicianNotes && }
+
+ {order.site && (
+
+ {order.site.name}
+ {address && {address}
}
+ {route && (
+
+
+ {t("detail.route")}
+
+ )}
+ {order.site.accessNotes && }
+ {order.site.parkingNotes && }
+ {order.site.safetyNotes && }
+ {order.site.technicalNotes && }
+ {order.site.onSiteContact && (
+
+ {t("detail.onSiteContact")}:
+ {order.site.onSiteContact}
+
+ )}
+
+
+ )}
+
+
+ {customerDisplayName(order.customer)}
+ {!order.site && address && {address}
}
+
+ {contact && (
+
+
{t("detail.contact")}
+
+ {contact.name}
+ {contact.role && · {contact.role} }
+
+
+
+ )}
+
+
+
+
+ {order.orderType && (
+ <>
+ {t("detail.orderType")}
+ {order.orderType.name}
+ >
+ )}
+ {order.externalOrderNumber && (
+ <>
+ {t("detail.externalNumber")}
+ {order.externalOrderNumber}
+ >
+ )}
+ {order.team && (
+ <>
+ {t("detail.team")}
+ {order.team.name}
+ >
+ )}
+
+ {order.description && {order.description}
}
+ {order.scope && (
+
+
{t("detail.scope")}
+
{order.scope}
+
+ )}
+
+
+
+ {order.documents.length === 0 ? (
+ {t("detail.noDocuments")}
+ ) : (
+
+ )}
+
+
+ {order.site && (
+
+ {order.siteHistory.length === 0 ? (
+ {t("detail.noHistory")}
+ ) : (
+
+ {order.siteHistory.map((h) => (
+
+
+
+ {t(`detail.reportType.${h.reportType}`)} · {fmtDate(h.reportDate, locale)}
+
+
+ {h.workOrderNumber} · {h.workOrderTitle}
+
+
+ {h.pdfDocumentId && (
+
+ {t("detail.openDocument")}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/photos/page.tsx b/src/app/(field)/m/(core)/orders/[id]/photos/page.tsx
new file mode 100644
index 0000000..9bb3a06
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/photos/page.tsx
@@ -0,0 +1,71 @@
+import { getLocale, getTranslations } from "next-intl/server";
+import { CircleCheck, CircleDot } from "lucide-react";
+import { fmtDateTime } from "@/lib/field/format";
+import { PhotoCapture } from "@/components/field/photo-capture";
+import { SubPageHeader } from "@/components/field/sub-page-header";
+import { card } from "@/components/field/ui";
+import { loadOrder } from "../load";
+
+/** `/m/orders/[id]/photos` — capture (camera/gallery) + gallery + required photos (Spec §14). */
+export default async function PhotosPage({ params }: { params: Promise<{ id: string }> }) {
+ const { order } = await loadOrder(params);
+ const t = await getTranslations("field");
+ const locale = await getLocale();
+ const requirementLabel = new Map(order.photoRequirements.map((r) => [r.id, r.label]));
+
+ return (
+
+
+
+
+ ({ id: r.id, label: r.label }))}
+ checklistItems={order.checklistItems.map((c) => ({ id: c.id, label: c.label }))}
+ />
+
+
+ {order.photoRequirements.length > 0 && (
+
+ {t("photos.requirements")}
+
+ {order.photoRequirements.map((r) => {
+ const done = r._count.photos > 0;
+ return (
+
+ {done ? : }
+ {r.label}
+ {done ? t("photos.done") : t("photos.missing")}
+
+ );
+ })}
+
+
+ )}
+
+ {order.photos.length === 0 ? (
+
{t("photos.empty")}
+ ) : (
+
+ {order.photos.map((p) => (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+ {p.phase ? t(`photos.phase.${p.phase}`) : "—"}
+ {p.photoRequirementId && requirementLabel.get(p.photoRequirementId) ? ` · ${requirementLabel.get(p.photoRequirementId)}` : ""}
+
+ {p.comment &&
{p.comment}
}
+
{fmtDateTime(p.takenAt, locale)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx
new file mode 100644
index 0000000..e5191b1
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx
@@ -0,0 +1,55 @@
+import { getLocale, getTranslations } from "next-intl/server";
+import { can } from "@/server/services/context";
+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 { loadOrder } from "../load";
+
+/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */
+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);
+
+ return (
+
+
+
+
{t("time.totalWork", { duration: fmtDuration(totalWork) })}
+ {order.workSessions.length === 0 &&
{t("time.empty")}
}
+ {order.workSessions.map((s) => (
+
+
+
{s.user.name}
+
+ {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 && }
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/orders/page.tsx b/src/app/(field)/m/(core)/orders/page.tsx
new file mode 100644
index 0000000..d20248e
--- /dev/null
+++ b/src/app/(field)/m/(core)/orders/page.tsx
@@ -0,0 +1,46 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+import { cn } from "@/lib/utils";
+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";
+import { chip } from "@/components/field/ui";
+
+/** `/m/orders` — tabs upcoming · running · to complete · past (Spec §11.2). */
+export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
+ const ctx = await fieldPageContext();
+ const t = await getTranslations("field");
+ if (!canUseFieldApp(ctx)) return {t("noAccess")}
;
+
+ 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);
+
+ return (
+
+ {t("orders.title")}
+
+
+ {ORDER_TABS.map((key) => (
+
+
+ {t(`orders.tabs.${key}`)}
+
+
+ ))}
+
+
+ {orders.length === 0 ? (
+ {t("orders.empty")}
+ ) : (
+
+ {orders.map((o) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/page.tsx b/src/app/(field)/m/(core)/page.tsx
new file mode 100644
index 0000000..0802068
--- /dev/null
+++ b/src/app/(field)/m/(core)/page.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+import { ChevronRight } from "lucide-react";
+import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
+import { listTodayOrders } from "@/server/services/field/queries";
+import { OrderCard } from "@/components/field/order-card";
+import { btnSecondary } from "@/components/field/ui";
+
+/** `/m` — Heute: today's, running and paused orders (Spec §11.2). */
+export default async function TodayPage() {
+ const ctx = await fieldPageContext();
+ const t = await getTranslations("field");
+ if (!canUseFieldApp(ctx)) return {t("noAccess")}
;
+
+ const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
+
+ return (
+
+
+
{t("today.greeting", { name: me?.name ?? "" })}
+
{t("today.title")}
+
{t("today.count", { count: orders.length })}
+
+ {orders.length === 0 ? (
+ {t("today.empty")}
+ ) : (
+
+ {orders.map((o) => (
+
+
+
+ ))}
+
+ )}
+
+ {t("today.allOrders")}
+
+
+
+ );
+}
diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx
new file mode 100644
index 0000000..6531a10
--- /dev/null
+++ b/src/app/(field)/m/(core)/profile/page.tsx
@@ -0,0 +1,73 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+import { LogOut } from "lucide-react";
+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 { fieldPageContext } from "@/server/services/field/page-context";
+import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
+import { btnSecondary, card } from "@/components/field/ui";
+
+/** `/m/profile` — name, team, company, language, sign out. */
+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 [me, teams, identity] = await Promise.all([
+ ctx.db.user.findFirst({
+ where: { id: ctx.userId },
+ select: { name: true, email: true, tenant: { select: { name: true } }, userRoles: { select: { role: { select: { name: true } } } } },
+ }),
+ teamIds.length ? ctx.db.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true }, orderBy: { name: "asc" } }) : Promise.resolve([]),
+ session?.user?.identityId ? prisma.identity.findUnique({ where: { id: session.user.identityId }, select: { uiLocale: true } }) : Promise.resolve(null),
+ ]);
+
+ async function logout() {
+ "use server";
+ await signOut({ redirectTo: "/login" });
+ }
+
+ const row = "flex flex-col gap-0.5 py-2.5";
+ return (
+
+ {t("title")}
+
+ {me?.name}
+ {me?.email}
+
+
+
{t("tenant")}
+ {me?.tenant.name}
+
+
+
{t("teams")}
+ {teams.length ? teams.map((tm) => tm.name).join(", ") : t("noTeam")}
+
+
+
{t("roles")}
+ {me?.userRoles.map((r) => r.role.name).join(", ")}
+
+
+
{t("language")}
+
+
+
+
+
+
+ {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
+
+ {t("backoffice")}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(app)/m/emergency/layout.tsx b/src/app/(field)/m/emergency/layout.tsx
similarity index 100%
rename from src/app/(app)/m/emergency/layout.tsx
rename to src/app/(field)/m/emergency/layout.tsx
diff --git a/src/app/(app)/m/emergency/page.tsx b/src/app/(field)/m/emergency/page.tsx
similarity index 100%
rename from src/app/(app)/m/emergency/page.tsx
rename to src/app/(field)/m/emergency/page.tsx
diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx
new file mode 100644
index 0000000..f42b53d
--- /dev/null
+++ b/src/app/(field)/m/layout.tsx
@@ -0,0 +1,34 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+import { requireAppAccess } from "@/server/app-access";
+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";
+
+/**
+ * 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".
+ */
+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");
+
+ return (
+
+ );
+}
diff --git a/src/app/(field)/m/sync/page.tsx b/src/app/(field)/m/sync/page.tsx
new file mode 100644
index 0000000..9a81ea5
--- /dev/null
+++ b/src/app/(field)/m/sync/page.tsx
@@ -0,0 +1,23 @@
+import { getTranslations } from "next-intl/server";
+import { OnlineBadge } from "@/components/field/online-badge";
+import { card } from "@/components/field/ui";
+
+/**
+ * PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page
+ * with the outbox status, errors and conflicts. Until then it shows the connection state and that
+ * ops are sent immediately (src/lib/field/client-ops.ts).
+ */
+export default async function SyncPlaceholderPage() {
+ const t = await getTranslations("field.sync");
+ return (
+
+ {t("title")}
+
+ {t("status")}
+
+ {t("immediate")}
+ {t("offlineHint")}
+
+
+ );
+}
diff --git a/src/app/login/mfa/page.tsx b/src/app/login/mfa/page.tsx
index 98718b3..7b0a641 100644
--- a/src/app/login/mfa/page.tsx
+++ b/src/app/login/mfa/page.tsx
@@ -46,7 +46,7 @@ export default async function LoginMfaPage({
// MFA erfüllt → pending entwerten und aus dem Ticket die Session prägen.
jar.delete(MFA_PENDING_COOKIE);
try {
- await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target });
+ await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite
} catch (err) {
if (err instanceof AuthError) redirect("/login?error=1");
throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchpropagieren
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 1b136f3..7024f41 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -30,7 +30,7 @@ export default async function LoginPage({
// Already signed in → straight to the app
const session = await auth();
- if (session?.user) redirect("/dashboard");
+ if (session?.user) redirect("/"); // rollenabhängige Startseite (src/app/page.tsx)
// WS5: Two-Step-Login, Schritt 1 (E-Mail + Passwort). Bei aktiver MFA wird ein
// kurzlebiger, einzweckiger `mfa_pending`-Cookie gesetzt und auf /login/mfa geleitet —
@@ -65,7 +65,7 @@ export default async function LoginPage({
}
try {
- await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target });
+ await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite
} catch (err) {
if (err instanceof AuthError) {
redirect(`/login?error=1${target !== "/dashboard" ? `&callbackUrl=${encodeURIComponent(target)}` : ""}`);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index a74cb27..2f55512 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,8 @@
import { redirect } from "next/navigation";
+import { auth } from "@/server/auth";
+import { landingPath } from "@/server/app-access";
-export default function Home() {
- redirect("/dashboard");
+export default async function Home() {
+ // Feldrollen → /m, Backoffice → /dashboard (ARCHITEKTUR §5)
+ redirect(landingPath(await auth()));
}
diff --git a/src/components/account-inactive-notice.tsx b/src/components/account-inactive-notice.tsx
new file mode 100644
index 0000000..b5e005b
--- /dev/null
+++ b/src/components/account-inactive-notice.tsx
@@ -0,0 +1,19 @@
+import { signOut } from "@/server/auth";
+import { Button } from "@/components/ui/button";
+import { CraftviaLogo } from "@/components/brand/craftvia-logo";
+
+/** Hinweis für deaktivierte Konten — gemeinsam für Backoffice- und Mobile-Shell (src/server/app-access.ts). */
+export function AccountInactiveNotice() {
+ return (
+
+
+
+
Konto deaktiviert
+
Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.
+
+
+
+ );
+}
diff --git a/src/components/field/bottom-nav.tsx b/src/components/field/bottom-nav.tsx
new file mode 100644
index 0000000..96f90f5
--- /dev/null
+++ b/src/components/field/bottom-nav.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const ITEMS = [
+ { href: "/m", key: "today", icon: House, exact: true },
+ { href: "/m/orders", key: "orders", icon: ClipboardList, exact: false },
+ { href: "/m/emergency", key: "emergency", icon: Siren, exact: false },
+ { href: "/m/sync", key: "sync", icon: RefreshCw, exact: false },
+ { href: "/m/profile", key: "profile", icon: User, exact: false },
+] as const;
+
+/** Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. */
+export function BottomNav() {
+ const t = useTranslations("field.nav");
+ const pathname = usePathname();
+ return (
+
+
+ {ITEMS.map((item) => {
+ const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
+ return (
+
+
+
+
+
+ {t(item.key)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/field/checklist-item.tsx b/src/components/field/checklist-item.tsx
new file mode 100644
index 0000000..af19097
--- /dev/null
+++ b/src/components/field/checklist-item.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Camera, Check, TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
+import { btnSecondary, card, inputClass, noticeError } from "./ui";
+
+type Item = { id: string; label: string; required: boolean; requiresPhoto: boolean; checked: boolean; comment: string | null };
+
+/** One checklist item (Spec §12.4): large toggle + optional comment. Optimistic, reverted on failure. */
+export function ChecklistItemRow({ workOrderId, item }: { workOrderId: string; item: Item }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [checked, setChecked] = useState(item.checked);
+ const [comment, setComment] = useState(item.comment ?? "");
+ const [editing, setEditing] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function send(nextChecked: boolean, nextComment?: string) {
+ setBusy(true);
+ setError(null);
+ const previous = checked;
+ setChecked(nextChecked);
+ const result = await submitOp({
+ opType: "checklist.toggle",
+ payload: { workOrderId, itemId: item.id, checked: nextChecked, ...(nextComment !== undefined ? { comment: nextComment } : {}) },
+ });
+ setBusy(false);
+ if (!isSuccess(result)) {
+ setChecked(previous);
+ setError(t(`errors.${errorKey(result)}`));
+ return;
+ }
+ setEditing(false);
+ router.refresh();
+ }
+
+ return (
+
+ send(!checked)}
+ className="flex min-h-14 w-full items-center gap-3 text-left"
+ >
+
+ {checked && }
+
+
+ {item.label}
+
+ {checked ? t("checklist.done") : t("checklist.open")}
+ {item.required && {t("checklist.required")} }
+ {item.requiresPhoto && (
+
+
+ {t("checklist.photo")}
+
+ )}
+
+
+
+ {editing ? (
+
+
+ ) : (
+ setEditing(true)} className="min-h-12 w-full rounded-xl px-1 text-left text-[14px] text-muted-foreground">
+ {comment ? comment : `${t("checklist.comment")} …`}
+
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/components/field/material-forms.tsx b/src/components/field/material-forms.tsx
new file mode 100644
index 0000000..ed4ad61
--- /dev/null
+++ b/src/components/field/material-forms.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { CircleCheck, TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { UNIT_SUGGESTIONS, type MaterialUsageStatus } from "@/lib/sync/ops";
+import { materialDeviates, validateMaterialUsage } from "@/lib/field/material-rules";
+import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { Stepper } from "./stepper";
+import { btnPrimary, card, chip, inputClass, noticeError, noticeOk } from "./ui";
+
+type PlanItem = { id: string; name: string; articleNumber: string | null; plannedQuantity: number; unit: string; notes: string | null };
+type Usage = { usageStatus: MaterialUsageStatus; quantity: number; unit: string; deviationReason: string | null } | null;
+
+const PLAN_STATUSES: MaterialUsageStatus[] = ["fully_used", "partially_used", "not_used"];
+const fmtQty = (n: number) => String(n).replace(".", ",");
+
+function Feedback({ error, saved, savedLabel }: { error: string | null; saved: boolean; savedLabel: string }) {
+ return (
+ <>
+ {error && (
+
+
+ {error}
+
+ )}
+ {saved && !error && (
+
+
+ {savedLabel}
+
+ )}
+ >
+ );
+}
+
+/** Confirm a planned material position: fully / partially / not used, quantity, reason on deviation. */
+export function MaterialPlanItem({ workOrderId, plan, usage }: { workOrderId: string; plan: PlanItem; usage: Usage }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [status, setStatus] = useState(usage?.usageStatus ?? "fully_used");
+ const [quantity, setQuantity] = useState(usage?.quantity ?? plan.plannedQuantity);
+ const [reason, setReason] = useState(usage?.deviationReason ?? "");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ const deviates = materialDeviates({ usageStatus: status, quantity }, plan.plannedQuantity);
+
+ function choose(s: MaterialUsageStatus) {
+ setStatus(s);
+ setSaved(false);
+ if (s === "fully_used") setQuantity(plan.plannedQuantity);
+ if (s === "not_used") setQuantity(0);
+ if (s === "partially_used" && (quantity <= 0 || quantity >= plan.plannedQuantity)) setQuantity(Math.max(0, Math.round((plan.plannedQuantity / 2) * 1000) / 1000));
+ }
+
+ async function save() {
+ setError(null);
+ const problem = validateMaterialUsage({ usageStatus: status, quantity, deviationReason: reason }, plan.plannedQuantity);
+ if (problem) {
+ setError(problem.startsWith("reason") ? t("materials.reasonRequired") : t("materials.invalid"));
+ return;
+ }
+ setBusy(true);
+ const result = await submitOp({
+ opType: "material.upsert",
+ payload: { workOrderId, materialPlanId: plan.id, quantity, unit: plan.unit, usageStatus: status, deviationReason: deviates ? reason.trim() : null },
+ });
+ setBusy(false);
+ if (!isSuccess(result)) {
+ setError(t(`errors.${errorKey(result)}`));
+ return;
+ }
+ setSaved(true);
+ router.refresh();
+ }
+
+ return (
+
+
+
{plan.name}
+ {plan.articleNumber &&
{plan.articleNumber}
}
+
{t("materials.planned", { quantity: fmtQty(plan.plannedQuantity), unit: plan.unit })}
+ {plan.notes &&
{plan.notes}
}
+
+ {usage ? t("materials.recorded", { quantity: fmtQty(usage.quantity), unit: usage.unit }) : t("materials.open")}
+
+
+
+ {PLAN_STATUSES.map((s) => (
+ choose(s)}>
+ {t(`materials.usage.${s}`)}
+
+ ))}
+
+ {status !== "not_used" && (
+ {
+ setQuantity(v);
+ setSaved(false);
+ }}
+ label={`${t("materials.quantity")} (${plan.unit})`}
+ decreaseLabel={t("materials.decrease")}
+ increaseLabel={t("materials.increase")}
+ />
+ )}
+ {deviates && (
+
+ {t("materials.reason")}
+
+ )}
+
+
+ {busy ? t("action.saving") : t("materials.save")}
+
+
+ );
+}
+
+/** Additional (unplanned) material: name, optional article number, quantity, unit suggestions, reason. */
+export function AdditionalMaterialForm({ workOrderId }: { workOrderId: string }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [clientId, setClientId] = useState(() => newClientId());
+ const [name, setName] = useState("");
+ const [articleNumber, setArticleNumber] = useState("");
+ const [quantity, setQuantity] = useState(1);
+ const [unit, setUnit] = useState(UNIT_SUGGESTIONS[0]);
+ const [reason, setReason] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ const problem = validateMaterialUsage({ usageStatus: "additional", quantity, name, deviationReason: reason }, null) ?? (unit.trim() ? null : "unit");
+ if (problem) {
+ setError(t("materials.invalid"));
+ return;
+ }
+ setBusy(true);
+ const result = await submitOp({
+ opType: "material.upsert",
+ payload: { workOrderId, clientId, name: name.trim(), articleNumber: articleNumber.trim() || null, quantity, unit: unit.trim(), usageStatus: "additional", deviationReason: reason.trim() },
+ });
+ setBusy(false);
+ if (!isSuccess(result)) {
+ setError(t(`errors.${errorKey(result)}`));
+ return;
+ }
+ setSaved(true);
+ setClientId(newClientId());
+ setName("");
+ setArticleNumber("");
+ setQuantity(1);
+ setReason("");
+ router.refresh();
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/field/note-form.tsx b/src/components/field/note-form.tsx
new file mode 100644
index 0000000..cf7bc8e
--- /dev/null
+++ b/src/components/field/note-form.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { CircleCheck, TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops";
+import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui";
+
+type Draft = { kind: NoteKind; text: string; clientId: string };
+
+const draftKey = (workOrderId: string) => `craftvia.field.noteDraft.${workOrderId}`;
+
+function readDraft(workOrderId: string): Draft | null {
+ try {
+ const raw = localStorage.getItem(draftKey(workOrderId));
+ return raw ? (JSON.parse(raw) as Draft) : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry
+ * stays idempotent) is kept in localStorage until the server confirmed it (US-006).
+ */
+export function NoteForm({ workOrderId }: { workOrderId: string }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [draft, setDraft] = useState({ kind: "work_done", text: "", clientId: "" });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ useEffect(() => {
+ const stored = readDraft(workOrderId);
+ // restore an unsent draft after reload / connection loss (client-only storage)
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ if (stored) setDraft(stored);
+ }, [workOrderId]);
+
+ function update(next: Partial) {
+ const value = { ...draft, ...next, clientId: draft.clientId || newClientId() };
+ setDraft(value);
+ setSaved(false);
+ try {
+ localStorage.setItem(draftKey(workOrderId), JSON.stringify(value));
+ } catch {
+ // storage unavailable (private mode) — the form still works
+ }
+ }
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!draft.text.trim()) return;
+ setBusy(true);
+ setError(null);
+ const clientId = draft.clientId || newClientId();
+ const result = await submitOp({ opType: "note.create", payload: { workOrderId, clientId, kind: draft.kind, text: draft.text.trim() } });
+ setBusy(false);
+ if (!isSuccess(result)) {
+ setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`);
+ return;
+ }
+ try {
+ localStorage.removeItem(draftKey(workOrderId));
+ } catch {
+ /* ignore */
+ }
+ setDraft({ kind: draft.kind, text: "", clientId: "" });
+ setSaved(true);
+ router.refresh();
+ }
+
+ return (
+
+
+ {t("notes.kindLabel")}
+
+ {NOTE_KINDS.map((k) => (
+ update({ kind: k })}>
+ {t(`notes.kind.${k}`)}
+
+ ))}
+
+
+
+ {t("notes.text")}
+ update({ text: e.target.value })}
+ />
+
+ {error && (
+
+
+ {error}
+
+ )}
+ {saved && (
+
+
+ {t("notes.saved")}
+
+ )}
+
+ {busy ? t("action.saving") : t("notes.save")}
+
+
+ );
+}
diff --git a/src/components/field/online-badge.tsx b/src/components/field/online-badge.tsx
new file mode 100644
index 0000000..7f7e6f1
--- /dev/null
+++ b/src/components/field/online-badge.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { useTranslations } from "next-intl";
+import { Wifi, WifiOff } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function subscribe(cb: () => void) {
+ window.addEventListener("online", cb);
+ window.addEventListener("offline", cb);
+ return () => {
+ window.removeEventListener("online", cb);
+ window.removeEventListener("offline", cb);
+ };
+}
+
+export function useOnline(): boolean {
+ return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
+}
+
+/** Online/offline indicator (navigator.onLine) — text + icon, never colour alone. */
+export function OnlineBadge({ large = false }: { large?: boolean }) {
+ const t = useTranslations("field.connection");
+ const online = useOnline();
+ const Icon = online ? Wifi : WifiOff;
+ return (
+
+
+ {online ? t("online") : t("offline")}
+
+ );
+}
diff --git a/src/components/field/order-card.tsx b/src/components/field/order-card.tsx
new file mode 100644
index 0000000..017c6a3
--- /dev/null
+++ b/src/components/field/order-card.tsx
@@ -0,0 +1,61 @@
+import Link from "next/link";
+import { useLocale, useTranslations } from "next-intl";
+import { ChevronRight, Clock, MapPin, Navigation, Siren } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { fmtWindow } from "@/lib/field/format";
+import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
+import { StatusBadge } from "./status-badge";
+import { btnPrimary, toneClasses } from "./ui";
+
+/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
+export function OrderCard({ order }: { order: OrderCardData }) {
+ const t = useTranslations("field.card");
+ const locale = useLocale();
+ const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
+ return (
+
+
+ {order.number}
+
+
+ {order.title}
+ {order.customerName}
+ {(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
+
+
+ {order.isEmergency ? t("emergency") : order.priority === "urgent" ? t("urgent") : t("high")}
+
+ )}
+
+
+
+
{window ?? t("noDate")}
+
+ {order.address && (
+
+
+
+ {order.siteName && {order.siteName} }
+ {order.address}
+
+ {order.mapsUrl && (
+
+
+ {t("route")}
+
+ )}
+
+ )}
+
+
+ {t("open")}
+
+
+
+ );
+}
diff --git a/src/components/field/photo-capture.tsx b/src/components/field/photo-capture.tsx
new file mode 100644
index 0000000..155c594
--- /dev/null
+++ b/src/components/field/photo-capture.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops";
+import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { compressImage, currentPosition } from "@/lib/field/image";
+import { uploadFieldFile } from "@/lib/field/upload";
+import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
+
+type Option = { id: string; label: string };
+type Meta = { phase: PhotoPhase | null; photoRequirementId: string | null; checklistItemId: string | null; comment: string; withLocation: boolean };
+type Progress = { stage: "idle" } | { stage: "compressing" } | { stage: "uploading"; percent: number } | { stage: "attaching" };
+
+const DIRECT_UPLOAD = /^image\/(jpeg|png|webp)$/;
+
+/** Compress → upload (with progress) → photo.attach. Keeps the uploaded documentId for retries. */
+function usePhotoSave(workOrderId: string) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [progress, setProgress] = useState({ stage: "idle" });
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const uploaded = useRef<{ file: File; documentId: string } | null>(null);
+
+ async function save(file: File, meta: Meta): Promise {
+ setError(null);
+ setSaved(false);
+ let documentId = uploaded.current?.file === file ? uploaded.current.documentId : null;
+ if (!documentId) {
+ setProgress({ stage: "compressing" });
+ let image: Blob = file;
+ let thumbnail: Blob | null = null;
+ try {
+ const out = await compressImage(file);
+ image = out.image;
+ thumbnail = out.thumbnail;
+ } catch {
+ if (!DIRECT_UPLOAD.test(file.type)) {
+ setProgress({ stage: "idle" });
+ setError(t("errors.image"));
+ return false;
+ }
+ }
+ setProgress({ stage: "uploading", percent: 0 });
+ const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
+ const up = await uploadFieldFile({
+ workOrderId,
+ kind: "photo",
+ clientId: newClientId(),
+ file: image,
+ fileName: thumbnail ? name : file.name || name,
+ preview: thumbnail,
+ onProgress: (percent) => setProgress({ stage: "uploading", percent }),
+ });
+ if (!up.ok) {
+ setProgress({ stage: "idle" });
+ setError(t(`errors.${up.error}`));
+ return false;
+ }
+ documentId = up.documentId;
+ uploaded.current = { file, documentId };
+ }
+ setProgress({ stage: "attaching" });
+ const pos = meta.withLocation ? await currentPosition() : null;
+ const result = await submitOp({
+ opType: "photo.attach",
+ payload: {
+ workOrderId,
+ clientId: newClientId(),
+ documentId,
+ phase: meta.phase,
+ photoRequirementId: meta.photoRequirementId,
+ checklistItemId: meta.checklistItemId,
+ comment: meta.comment.trim() || null,
+ takenAt: new Date(Math.min(file.lastModified || Date.now(), Date.now())).toISOString(),
+ ...(pos ?? {}),
+ },
+ });
+ setProgress({ stage: "idle" });
+ if (!isSuccess(result)) {
+ setError(t(`errors.${errorKey(result)}`));
+ return false;
+ }
+ uploaded.current = null;
+ setSaved(true);
+ router.refresh();
+ return true;
+ }
+
+ const label =
+ progress.stage === "compressing"
+ ? t("photos.compressing")
+ : progress.stage === "uploading"
+ ? t("photos.uploading", { percent: progress.percent })
+ : progress.stage === "attaching"
+ ? t("action.saving")
+ : null;
+ return { save, progress, label, error, saved, busy: progress.stage !== "idle" };
+}
+
+function ProgressBar({ progress, label }: { progress: Progress; label: string | null }) {
+ if (!label) return null;
+ const percent = progress.stage === "uploading" ? progress.percent : progress.stage === "attaching" ? 100 : 5;
+ return (
+
+ );
+}
+
+/** Quick camera button on the order detail: one tap opens the camera, the photo is saved as "during". */
+export function QuickPhotoButton({ workOrderId, label }: { workOrderId: string; label: string }) {
+ const { save, progress, label: progressLabel, error, busy } = usePhotoSave(workOrderId);
+ const t = useTranslations("field.photos");
+ const [done, setDone] = useState(false);
+ return (
+
+
+ {busy ? : }
+ {label}
+ {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (file) setDone(await save(file, { phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false }));
+ }}
+ />
+
+ {(busy || error || done) && (
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+ {done && !busy && !error && (
+
+
+ {t("saved")}
+
+ )}
+
+ )}
+
+ );
+}
+
+/** Full photo capture: camera or gallery, phase, required photo, checklist item, comment, optional location. */
+export function PhotoCapture({ workOrderId, requirements, checklistItems }: { workOrderId: string; requirements: Option[]; checklistItems: Option[] }) {
+ const t = useTranslations("field.photos");
+ const { save, progress, label, error, saved, busy } = usePhotoSave(workOrderId);
+ const [file, setFile] = useState(null);
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const [meta, setMeta] = useState ({ phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false });
+
+ const showFile = (f: File | null) => {
+ setPreviewUrl((old) => {
+ if (old) URL.revokeObjectURL(old);
+ return f ? URL.createObjectURL(f) : null;
+ });
+ setFile(f);
+ };
+
+ const pick = (e: React.ChangeEvent) => {
+ const f = e.target.files?.[0];
+ e.target.value = "";
+ if (f) showFile(f);
+ };
+
+ if (!file) {
+ return (
+
+ );
+ }
+
+ return (
+ {
+ e.preventDefault();
+ if (await save(file, meta)) {
+ showFile(null);
+ setMeta((m) => ({ ...m, comment: "", photoRequirementId: null, checklistItemId: null }));
+ }
+ }}
+ >
+ {previewUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ {t("phaseLabel")}
+
+ {PHOTO_PHASES.map((p) => (
+ setMeta({ ...meta, phase: p })}>
+ {t(`phase.${p}`)}
+
+ ))}
+
+
+ {requirements.length > 0 && (
+
+ {t("requirement")}
+ setMeta({ ...meta, photoRequirementId: e.target.value || null })}>
+ {t("none")}
+ {requirements.map((r) => (
+
+ {r.label}
+
+ ))}
+
+
+ )}
+ {checklistItems.length > 0 && (
+
+ {t("checklistItem")}
+ setMeta({ ...meta, checklistItemId: e.target.value || null })}>
+ {t("none")}
+ {checklistItems.map((c) => (
+
+ {c.label}
+
+ ))}
+
+
+ )}
+
+ {t("comment")}
+ setMeta({ ...meta, comment: e.target.value })} />
+
+
+ setMeta({ ...meta, withLocation: e.target.checked })} />
+ {t("location")}
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+ {t("save")}
+
+ showFile(null)}>
+ {t("discard")}
+
+
+ );
+}
diff --git a/src/components/field/primary-action.tsx b/src/components/field/primary-action.tsx
new file mode 100644
index 0000000..157779f
--- /dev/null
+++ b/src/components/field/primary-action.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { CircleCheck, LoaderCircle, Pause, Play, TriangleAlert, Truck, Wrench, Handshake } from "lucide-react";
+import type { WorkOrderStatus } from "@/lib/work-orders/status";
+import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { currentPosition } from "@/lib/field/image";
+import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
+
+type SessionState = "en_route" | "running" | "paused" | null;
+type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "complete";
+
+const WORKING: WorkOrderStatus[] = ["in_progress", "paused", "waiting_material", "daily_report_created"];
+
+/** One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter → Abschließen. */
+export function resolveActions(status: WorkOrderStatus, mySession: SessionState): { primary: ActionKey | null; secondary: ActionKey | null } {
+ if (status === "assigned") return { primary: "accept", secondary: null };
+ if (status === "accepted") return { primary: "travel", secondary: "start" };
+ if (status === "en_route") return { primary: "start", secondary: null };
+ if (WORKING.includes(status)) {
+ if (mySession === "running") return { primary: "complete", secondary: "pause" };
+ if (mySession === "paused") return { primary: "resume", secondary: "complete" };
+ return { primary: "start", secondary: status === "in_progress" ? "complete" : null };
+ }
+ return { primary: null, secondary: null };
+}
+
+const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, complete: CircleCheck } as const;
+
+async function sessionStartPayload(workOrderId: string, mode: "travel" | "work") {
+ const pos = await currentPosition(3000);
+ return {
+ workOrderId,
+ mode,
+ clientId: newClientId(),
+ at: new Date().toISOString(),
+ offline: typeof navigator !== "undefined" ? !navigator.onLine : false,
+ deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined,
+ ...(pos ?? {}),
+ };
+}
+
+export function PrimaryAction({
+ workOrderId,
+ status,
+ version,
+ mySession,
+ blockers,
+}: {
+ workOrderId: string;
+ status: WorkOrderStatus;
+ version: number;
+ mySession: SessionState;
+ /** translated completion blockers (without the user's own session, which is ended on completion) */
+ blockers: string[];
+}) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState(null);
+ const [confirm, setConfirm] = useState(false);
+ const { primary, secondary } = resolveActions(status, mySession);
+
+ async function run(action: ActionKey) {
+ if (action === "complete" && !confirm) {
+ setConfirm(true);
+ return;
+ }
+ setBusy(action);
+ setError(null);
+ const at = new Date().toISOString();
+ let result;
+ switch (action) {
+ case "accept":
+ result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } });
+ break;
+ case "travel":
+ result = await submitOp({ opType: "session.start", payload: await sessionStartPayload(workOrderId, "travel") });
+ break;
+ case "start":
+ result = await submitOp({ opType: "session.start", payload: await sessionStartPayload(workOrderId, "work") });
+ break;
+ case "pause":
+ result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } });
+ break;
+ case "resume":
+ result = await submitOp({ opType: "session.resume", payload: { workOrderId, at } });
+ break;
+ case "complete": {
+ let base = version;
+ if (mySession) {
+ const ended = await submitOp({ opType: "session.end", payload: { workOrderId, at } });
+ if (!isSuccess(ended)) {
+ result = ended;
+ break;
+ }
+ base = ended.entityVersion ?? version;
+ }
+ result = await submitOp({ opType: "work_order.transition", baseVersion: base, payload: { workOrderId, to: "technically_completed" } });
+ break;
+ }
+ }
+ setBusy(null);
+ setConfirm(false);
+ if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
+ router.refresh();
+ }
+
+ if (!primary) {
+ return {["technically_completed", "signature_pending"].includes(status) ? t("action.reportNext") : t("action.nothing")}
;
+ }
+
+ const completeBlocked = blockers.length > 0;
+ const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
+ const Icon = busy === action ? LoaderCircle : ICONS[action];
+ const disabled = busy !== null || (action === "complete" && completeBlocked);
+ return (
+ run(action)} disabled={disabled} className={variant === "primary" ? btnPrimary : btnSecondary}>
+
+ {busy === action ? t("action.saving") : action === "complete" && confirm ? t("action.confirmComplete") : t(`action.${action}`)}
+
+ );
+ };
+
+ return (
+
+ {renderButton(primary, "primary")}
+ {secondary && renderButton(secondary, "secondary")}
+ {confirm && (
+
+
+
+ {t("action.confirmHint")}{" "}
+ setConfirm(false)}>
+ {t("photos.discard")}
+
+
+
+ )}
+ {completeBlocked && (primary === "complete" || secondary === "complete") && (
+
+
+
+
{t("action.blocked")}
+
+ {blockers.map((b) => (
+ {b}
+ ))}
+
+
+
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/components/field/status-badge.tsx b/src/components/field/status-badge.tsx
new file mode 100644
index 0000000..a86ebfd
--- /dev/null
+++ b/src/components/field/status-badge.tsx
@@ -0,0 +1,33 @@
+import { useTranslations } from "next-intl";
+import { Ban, BadgeCheck, CalendarClock, CircleDot, FileSearch, Receipt, TriangleAlert, Truck, Wrench, type LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
+import { toneClasses } from "./ui";
+
+const ICONS: Record = {
+ new: CircleDot,
+ planned: CalendarClock,
+ en_route: Truck,
+ in_progress: Wrench,
+ documentation_incomplete: TriangleAlert,
+ in_review: FileSearch,
+ ready_for_billing: BadgeCheck,
+ billed: Receipt,
+ cancelled: Ban,
+};
+
+/** Status group (Brandbook §12.3) as text + icon; the detailed status is appended when it differs. */
+export function StatusBadge({ status, large = false }: { status: WorkOrderStatus; large?: boolean }) {
+ const t = useTranslations("field");
+ const group = STATUS_GROUP[status];
+ const Icon = ICONS[group];
+ const groupLabel = t(`statusGroup.${group}`);
+ const detail = t(`statusDetail.${status}`);
+ return (
+
+
+ {groupLabel}
+ {detail !== groupLabel && · {detail} }
+
+ );
+}
diff --git a/src/components/field/stepper.tsx b/src/components/field/stepper.tsx
new file mode 100644
index 0000000..d61d3d4
--- /dev/null
+++ b/src/components/field/stepper.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { Minus, Plus } from "lucide-react";
+import { inputClass } from "./ui";
+import { cn } from "@/lib/utils";
+
+/** Quantity stepper with large +/− buttons (Spec §22: material with few inputs). */
+export function Stepper({
+ value,
+ onChange,
+ label,
+ decreaseLabel,
+ increaseLabel,
+ step = 1,
+ min = 0,
+}: {
+ value: number;
+ onChange: (value: number) => void;
+ label: string;
+ decreaseLabel: string;
+ increaseLabel: string;
+ step?: number;
+ min?: number;
+}) {
+ const round = (n: number) => Math.round(n * 1000) / 1000;
+ const btn = "grid size-14 shrink-0 place-items-center rounded-xl border bg-card text-primary hover:bg-muted disabled:opacity-40";
+ return (
+
+
onChange(Math.max(min, round(value - step)))}>
+
+
+
{
+ const n = Number(e.target.value.replace(",", "."));
+ if (Number.isFinite(n) && n >= min) onChange(round(n));
+ else if (e.target.value.trim() === "") onChange(min);
+ }}
+ />
+
onChange(round(value + step))}>
+
+
+
+ );
+}
diff --git a/src/components/field/sub-page-header.tsx b/src/components/field/sub-page-header.tsx
new file mode 100644
index 0000000..3f4d3d5
--- /dev/null
+++ b/src/components/field/sub-page-header.tsx
@@ -0,0 +1,20 @@
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { ChevronLeft } from "lucide-react";
+
+/** Header of the order sub pages (max. two navigation levels below the order list). */
+export function SubPageHeader({ workOrderId, number, title, section }: { workOrderId: string; number: string; title: string; section: string }) {
+ const t = useTranslations("field.detail");
+ return (
+
+
+
+ {t("backToOrder")}
+
+
+ {number} · {title}
+
+
{section}
+
+ );
+}
diff --git a/src/components/field/time-correction-form.tsx b/src/components/field/time-correction-form.tsx
new file mode 100644
index 0000000..fcbc05a
--- /dev/null
+++ b/src/components/field/time-correction-form.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { correctTime } from "@/server/actions/field/time";
+import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui";
+
+/** ISO → value of in the device time zone. */
+function toLocalInput(iso: string): string {
+ const d = new Date(iso);
+ return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
+}
+
+/** Manual correction of a time segment (Spec §12.2) — only rendered for users with field:correct_time. */
+export function TimeCorrectionForm({ workOrderId, entry }: { workOrderId: string; entry: { id: 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 [reason, setReason] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ if (!open) {
+ return (
+ {
+ // inputs are prefilled only on the client (device time zone), never during SSR
+ setStart(toLocalInput(entry.startedAt));
+ setEnd(entry.endedAt ? toLocalInput(entry.endedAt) : "");
+ setOpen(true);
+ }}
+ >
+ {t("time.correct")}
+
+ );
+ }
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ if (reason.trim().length < 3) {
+ setError(t("time.reasonHint"));
+ return;
+ }
+ setBusy(true);
+ const result = await correctTime({
+ workOrderId,
+ timeEntryId: entry.id,
+ startedAt: new Date(start).toISOString(),
+ endedAt: end ? new Date(end).toISOString() : null,
+ reason: reason.trim(),
+ }).catch(() => ({ ok: false as const, error: "failed" as const }));
+ setBusy(false);
+ if (!result.ok) {
+ setError(t(`errors.${result.error === "failed" ? "internal" : result.error}`));
+ return;
+ }
+ setOpen(false);
+ setReason("");
+ router.refresh();
+ }
+
+ return (
+
+
+
+ {t("time.start")}
+ setStart(e.target.value)} />
+
+
+ {t("time.end")}
+ setEnd(e.target.value)} />
+
+
+
+ {t("time.reason")}
+ setReason(e.target.value)} />
+ {t("time.reasonHint")}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {busy ? t("action.saving") : t("time.save")}
+
+ setOpen(false)}>
+ {t("photos.discard")}
+
+
+ );
+}
diff --git a/src/components/field/ui.ts b/src/components/field/ui.ts
new file mode 100644
index 0000000..3bafe92
--- /dev/null
+++ b/src/components/field/ui.ts
@@ -0,0 +1,40 @@
+import { cn } from "@/lib/utils";
+import type { StatusGroup } from "@/lib/work-orders/status";
+import { STATUS_GROUP_TONE } from "@/lib/work-orders/status";
+
+/** Shared class names of the mobile field UI (touch targets ≥ 48 px, colours only via tokens). */
+
+export const btnPrimary =
+ "inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-cta px-5 font-heading text-base font-semibold text-cta-foreground transition-opacity hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
+
+export const btnSecondary =
+ "inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-primary transition-colors hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
+
+export function chip(active: boolean) {
+ return cn(
+ "inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border px-3.5 text-[14px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
+ active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-muted",
+ );
+}
+
+export const card = "rounded-xl border bg-card p-4 shadow-card";
+
+export const inputClass =
+ "min-h-12 w-full rounded-xl border border-input bg-card px-3.5 text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
+
+const TONE_CLASSES = {
+ neutral: { badge: "bg-muted text-muted-foreground", edge: "border-l-[var(--brand-stahlgrau)]" },
+ info: { badge: "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]", edge: "border-l-[var(--info)]" },
+ accent: { badge: "bg-[color-mix(in_oklch,var(--ui-accent)_14%,transparent)] text-foreground", edge: "border-l-[var(--ui-accent)]" },
+ warning: { badge: "bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-[var(--warn)]", edge: "border-l-[var(--warn)]" },
+ success: { badge: "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]", edge: "border-l-[var(--ok)]" },
+ danger: { badge: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]", edge: "border-l-[var(--risk)]" },
+} as const;
+
+export function toneClasses(group: StatusGroup) {
+ return TONE_CLASSES[STATUS_GROUP_TONE[group]];
+}
+
+export const noticeError = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--risk)_10%,transparent)] px-3.5 py-3 text-[14px] text-[var(--risk)]";
+export const noticeOk = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--ok)_10%,transparent)] px-3.5 py-3 text-[14px] text-[var(--ok)]";
+export const noticeWarn = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--warn)_12%,transparent)] px-3.5 py-3 text-[14px] text-foreground";
diff --git a/src/components/field/voice-recorder.tsx b/src/components/field/voice-recorder.tsx
new file mode 100644
index 0000000..4cff881
--- /dev/null
+++ b/src/components/field/voice-recorder.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react";
+import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
+import { uploadFieldFile } from "@/lib/field/upload";
+import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui";
+
+export const MAX_RECORDING_SECONDS = 300;
+
+function pickMimeType(): string | undefined {
+ if (typeof MediaRecorder === "undefined") return undefined;
+ for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) {
+ if (MediaRecorder.isTypeSupported(type)) return type;
+ }
+ return undefined;
+}
+
+const extensionFor = (mime: string) => (mime.includes("mp4") ? "m4a" : mime.includes("ogg") ? "ogg" : "webm");
+const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+
+/** Voice note (Spec §15.1): MediaRecorder (webm/opus, mp4 on iOS), max. 5 minutes, then upload + voice.attach. */
+export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
+ const t = useTranslations("field");
+ const router = useRouter();
+ const [state, setState] = useState<"idle" | "recording" | "recorded" | "saving">("idle");
+ const [seconds, setSeconds] = useState(0);
+ const [clip, setClip] = useState<{ blob: Blob; url: string } | null>(null);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const [percent, setPercent] = useState(0);
+ const recorder = useRef(null);
+ const timer = useRef | null>(null);
+
+ useEffect(
+ () => () => {
+ if (timer.current) clearInterval(timer.current);
+ recorder.current?.stream.getTracks().forEach((tr) => tr.stop());
+ },
+ [],
+ );
+ useEffect(() => () => (clip ? URL.revokeObjectURL(clip.url) : undefined), [clip]);
+
+ const supported = typeof window === "undefined" || (typeof MediaRecorder !== "undefined" && !!navigator.mediaDevices?.getUserMedia);
+
+ function stop() {
+ if (timer.current) clearInterval(timer.current);
+ timer.current = null;
+ if (recorder.current?.state === "recording") recorder.current.stop();
+ }
+
+ async function start() {
+ setError(null);
+ setSaved(false);
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const mimeType = pickMimeType();
+ const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
+ const chunks: BlobPart[] = [];
+ rec.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data);
+ rec.onstop = () => {
+ stream.getTracks().forEach((tr) => tr.stop());
+ const blob = new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" });
+ setClip({ blob, url: URL.createObjectURL(blob) });
+ setState("recorded");
+ };
+ recorder.current = rec;
+ rec.start(1000);
+ setSeconds(0);
+ setState("recording");
+ timer.current = setInterval(() => {
+ setSeconds((s) => {
+ if (s + 1 >= MAX_RECORDING_SECONDS) stop();
+ return Math.min(s + 1, MAX_RECORDING_SECONDS);
+ });
+ }, 1000);
+ } catch {
+ setError(t("voice.denied"));
+ setState("idle");
+ }
+ }
+
+ async function save() {
+ if (!clip) return;
+ setState("saving");
+ setError(null);
+ const type = clip.blob.type || "audio/webm";
+ const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent });
+ if (!up.ok) {
+ setError(t(`errors.${up.error}`));
+ setState("recorded");
+ return;
+ }
+ const result = await submitOp({
+ opType: "voice.attach",
+ payload: { workOrderId, clientId: newClientId(), documentId: up.documentId, durationSeconds: Math.min(seconds, MAX_RECORDING_SECONDS), recordedAt: new Date().toISOString() },
+ });
+ if (!isSuccess(result)) {
+ setError(t(`errors.${errorKey(result)}`));
+ setState("recorded");
+ return;
+ }
+ setClip(null);
+ setSaved(true);
+ setState("idle");
+ router.refresh();
+ }
+
+ if (!supported) return {t("voice.unsupported")}
;
+
+ return (
+
+ {state === "idle" && (
+
+
+ {t("voice.record")}
+
+ )}
+ {state === "recording" && (
+ <>
+
+
+ {t("voice.recording", { time: clock(seconds) })}
+
+
+
+ {t("voice.stop")}
+
+ >
+ )}
+ {(state === "recorded" || state === "saving") && clip && (
+ <>
+
+ {state === "saving" && (
+
+
+ {t("photos.uploading", { percent })}
+
+ )}
+
+
+ {t("voice.save")}
+
+
{ setClip(null); setState("idle"); }} disabled={state === "saving"}>
+ {t("voice.discard")}
+
+ >
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+ {saved && (
+
+
+ {t("voice.saved")}
+
+ )}
+
+ );
+}
diff --git a/src/lib/field/client-ops.ts b/src/lib/field/client-ops.ts
new file mode 100644
index 0000000..1b3ce96
--- /dev/null
+++ b/src/lib/field/client-ops.ts
@@ -0,0 +1,78 @@
+import type { SyncOpResult, SyncOpType, SyncResponse } from "@/lib/sync/envelope";
+import type { OpPayload } from "@/lib/sync/ops";
+
+/**
+ * Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Today every op is sent to
+ * POST /api/v1/sync immediately. Lane L7 replaces the implementation with the IndexedDB outbox —
+ * keep the signature `submitOp(op) → Promise` stable.
+ */
+
+export type ClientOp = {
+ opType: T;
+ payload: OpPayload;
+ entityType?: string;
+ entityId?: string;
+ baseVersion?: number;
+};
+
+/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */
+export function newClientId(): string {
+ const c = globalThis.crypto;
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
+ const b = new Uint8Array(16);
+ c.getRandomValues(b);
+ b[6] = (b[6] & 0x0f) | 0x40;
+ b[8] = (b[8] & 0x3f) | 0x80;
+ const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
+}
+
+const DEVICE_KEY = "craftvia.field.deviceId";
+
+export function deviceId(): string {
+ try {
+ let id = localStorage.getItem(DEVICE_KEY);
+ if (!id) {
+ id = newClientId();
+ localStorage.setItem(DEVICE_KEY, id);
+ }
+ return id;
+ } catch {
+ return "unknown-device";
+ }
+}
+
+function errorCodeForStatus(status: number): SyncOpResult["errorCode"] {
+ if (status === 400 || status === 413) return "invalid";
+ if (status === 401 || status === 403) return "forbidden";
+ if (status === 404) return "not_found";
+ if (status === 409) return "conflict";
+ return "internal";
+}
+
+export async function submitOp(op: ClientOp): Promise {
+ const clientOpId = newClientId();
+ try {
+ const res = await fetch("/api/v1/sync", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ deviceId: deviceId(), operations: [{ ...op, clientOpId, clientCreatedAt: new Date().toISOString() }] }),
+ });
+ if (!res.ok) return { clientOpId, status: "rejected", errorCode: errorCodeForStatus(res.status), message: `HTTP ${res.status}` };
+ const body = (await res.json()) as SyncResponse;
+ return body.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" };
+ } catch {
+ return { clientOpId, status: "rejected", errorCode: "internal", message: "network" };
+ }
+}
+
+/** i18n key (messages field.errors.*) for a failed op result. */
+export function errorKey(result: SyncOpResult): "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal" | "network" {
+ if (result.message === "network") return "network";
+ return result.errorCode ?? "internal";
+}
+
+export function isSuccess(result: SyncOpResult): boolean {
+ return result.status === "applied" || (result.status === "duplicate" && !result.errorCode);
+}
diff --git a/src/lib/field/format.ts b/src/lib/field/format.ts
new file mode 100644
index 0000000..eff44e8
--- /dev/null
+++ b/src/lib/field/format.ts
@@ -0,0 +1,42 @@
+/** Date/time formatting of the mobile app with an explicit time zone (identical on server and client). */
+
+export const FIELD_TIME_ZONE = "Europe/Berlin";
+
+const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE");
+
+export function fmtTime(d: Date | string, locale: string): string {
+ return new Intl.DateTimeFormat(tag(locale), { hour: "2-digit", minute: "2-digit", timeZone: FIELD_TIME_ZONE }).format(new Date(d));
+}
+
+export function fmtDate(d: Date | string, locale: string): string {
+ return new Intl.DateTimeFormat(tag(locale), { weekday: "short", day: "2-digit", month: "2-digit", timeZone: FIELD_TIME_ZONE }).format(new Date(d));
+}
+
+export function fmtDateTime(d: Date | string, locale: string): string {
+ return `${fmtDate(d, locale)} ${fmtTime(d, locale)}`;
+}
+
+function dayKey(d: Date): string {
+ return new Intl.DateTimeFormat("en-CA", { timeZone: FIELD_TIME_ZONE }).format(d);
+}
+
+/** "Mo., 14.09. · 08:00–12:00" or "Mo., 14.09. 08:00 – Di., 15.09. 12:00"; null without start. */
+export function fmtWindow(start: Date | string | null, end: Date | string | null, locale: string): string | null {
+ if (!start) return null;
+ const s = new Date(start);
+ if (!end) return `${fmtDate(s, locale)} · ${fmtTime(s, locale)}`;
+ const e = new Date(end);
+ if (dayKey(s) === dayKey(e)) return `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${fmtTime(e, locale)}`;
+ return `${fmtDateTime(s, locale)} – ${fmtDateTime(e, locale)}`;
+}
+
+export function fmtDuration(seconds: number): string {
+ const total = Math.max(0, Math.round(seconds / 60));
+ const h = Math.floor(total / 60);
+ const m = total % 60;
+ return h > 0 ? `${h} h ${String(m).padStart(2, "0")} min` : `${m} min`;
+}
+
+export function secondsBetween(start: Date | string, end: Date | string | null, now = new Date()): number {
+ return Math.max(0, Math.round(((end ? new Date(end) : now).getTime() - new Date(start).getTime()) / 1000));
+}
diff --git a/src/lib/field/image.ts b/src/lib/field/image.ts
new file mode 100644
index 0000000..33ade07
--- /dev/null
+++ b/src/lib/field/image.ts
@@ -0,0 +1,81 @@
+/**
+ * In-browser photo compression before upload (ARCHITEKTUR §4.3, Spec §14.4):
+ * longest edge max 2560 px, JPEG quality 0.82, plus a 400 px thumbnail.
+ * EXIF orientation: decoded with `imageOrientation: "from-image"` (createImageBitmap) — modern
+ * browsers also apply it for decoding, which is the fallback path.
+ */
+
+export const MAX_EDGE = 2560;
+export const JPEG_QUALITY = 0.82;
+export const THUMB_EDGE = 400;
+
+type Drawable = { source: CanvasImageSource; width: number; height: number; close: () => void };
+
+async function decode(file: Blob): Promise {
+ if (typeof createImageBitmap === "function") {
+ try {
+ const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
+ return { source: bmp, width: bmp.width, height: bmp.height, close: () => bmp.close() };
+ } catch {
+ // fall through to decoding (e.g. older Safari)
+ }
+ }
+ const url = URL.createObjectURL(file);
+ try {
+ const img = await new Promise((resolve, reject) => {
+ const el = new Image();
+ el.onload = () => resolve(el);
+ el.onerror = () => reject(new Error("decode failed"));
+ el.src = url;
+ });
+ return { source: img, width: img.naturalWidth, height: img.naturalHeight, close: () => URL.revokeObjectURL(url) };
+ } catch (err) {
+ URL.revokeObjectURL(url);
+ throw err;
+ }
+}
+
+function render(d: Drawable, maxEdge: number, quality: number): Promise {
+ const scale = Math.min(1, maxEdge / Math.max(d.width, d.height));
+ const w = Math.max(1, Math.round(d.width * scale));
+ const h = Math.max(1, Math.round(d.height * scale));
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ const g = canvas.getContext("2d");
+ if (!g) return Promise.reject(new Error("canvas unavailable"));
+ g.imageSmoothingQuality = "high";
+ g.drawImage(d.source, 0, 0, w, h);
+ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("encode failed"))), "image/jpeg", quality));
+}
+
+export async function compressImage(file: Blob): Promise<{ image: Blob; thumbnail: Blob; width: number; height: number }> {
+ const d = await decode(file);
+ try {
+ const image = await render(d, MAX_EDGE, JPEG_QUALITY);
+ const thumbnail = await render(d, THUMB_EDGE, 0.75);
+ const scale = Math.min(1, MAX_EDGE / Math.max(d.width, d.height));
+ return { image, thumbnail, width: Math.round(d.width * scale), height: Math.round(d.height * scale) };
+ } finally {
+ d.close();
+ }
+}
+
+/** Optional GPS position (never blocks longer than `timeoutMs`, null when denied/unavailable). */
+export function currentPosition(timeoutMs = 4000): Promise<{ latitude: number; longitude: number } | null> {
+ if (typeof navigator === "undefined" || !navigator.geolocation) return Promise.resolve(null);
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(null), timeoutMs + 500);
+ navigator.geolocation.getCurrentPosition(
+ (p) => {
+ clearTimeout(timer);
+ resolve({ latitude: p.coords.latitude, longitude: p.coords.longitude });
+ },
+ () => {
+ clearTimeout(timer);
+ resolve(null);
+ },
+ { enableHighAccuracy: false, timeout: timeoutMs, maximumAge: 120_000 },
+ );
+ });
+}
diff --git a/src/lib/field/upload.ts b/src/lib/field/upload.ts
new file mode 100644
index 0000000..a14cd0f
--- /dev/null
+++ b/src/lib/field/upload.ts
@@ -0,0 +1,48 @@
+/**
+ * Binary upload to POST /api/v1/uploads with progress (XHR — fetch has no upload progress).
+ * Idempotent over `clientId`: retrying the same clientId returns the same documentId.
+ */
+
+export type UploadResult = { ok: true; documentId: string } | { ok: false; error: "network" | "invalid" | "forbidden" | "not_found" | "internal" };
+
+export function uploadFieldFile(opts: {
+ workOrderId: string;
+ kind: "photo" | "voice_note";
+ clientId: string;
+ file: Blob;
+ fileName: string;
+ preview?: Blob | null;
+ onProgress?: (percent: number) => void;
+}): Promise {
+ return new Promise((resolve) => {
+ const form = new FormData();
+ form.append("clientId", opts.clientId);
+ form.append("workOrderId", opts.workOrderId);
+ form.append("kind", opts.kind);
+ form.append("file", opts.file, opts.fileName);
+ if (opts.preview) form.append("preview", opts.preview, `thumb-${opts.fileName}`);
+
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", "/api/v1/uploads");
+ xhr.withCredentials = true;
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) opts.onProgress?.(Math.round((e.loaded / e.total) * 100));
+ };
+ xhr.onerror = () => resolve({ ok: false, error: "network" });
+ xhr.onload = () => {
+ if (xhr.status === 200 || xhr.status === 201) {
+ try {
+ const body = JSON.parse(xhr.responseText) as { documentId: string };
+ opts.onProgress?.(100);
+ resolve({ ok: true, documentId: body.documentId });
+ } catch {
+ resolve({ ok: false, error: "internal" });
+ }
+ return;
+ }
+ const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
+ resolve({ ok: false, error });
+ };
+ xhr.send(form);
+ });
+}
diff --git a/src/server/app-access.ts b/src/server/app-access.ts
new file mode 100644
index 0000000..6f08efe
--- /dev/null
+++ b/src/server/app-access.ts
@@ -0,0 +1,72 @@
+import { redirect } from "next/navigation";
+import type { Session } from "next-auth";
+import { auth, signOut } from "@/server/auth";
+import { dbForTenant, prisma } from "@/server/db";
+import { isTokenStillValid } from "@/server/sessions";
+import { resolveMfaRequired } from "@/lib/mfa-policy";
+
+/**
+ * Gemeinsame Zugriffsprüfung der angemeldeten Mandanten-Shells (Backoffice `(app)/layout.tsx`
+ * und Mobile `(field)/m/layout.tsx`). Aus dem Backoffice-Layout extrahiert (ARCHITEKTUR §5),
+ * damit beide Shells identisch prüfen:
+ * Session → aktiver Mandant → Kill-Switch → Konto-/Identity-Status → Passwortzwang → MFA-Gate.
+ * Redirects werden hier ausgelöst; ein deaktiviertes Konto wird als `inactive` gemeldet,
+ * die aufrufende Shell rendert dafür `AccountInactiveNotice`.
+ */
+export type AppAccess =
+ | { kind: "inactive" }
+ | { kind: "ok"; session: Session; identity: { uiLocale: string } };
+
+export async function requireAppAccess(): Promise {
+ const session = await auth();
+ if (!session?.user) redirect("/login");
+ // Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) →
+ // Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
+ if (!session.user.tenantId) redirect("/select-tenant");
+
+ // Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
+ // abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
+ // User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
+ const account = await dbForTenant(session.user.tenantId).user.findUnique({
+ where: { id: session.user.id },
+ select: { status: true },
+ });
+ const identity = session.user.identityId
+ ? await prisma.identity.findUnique({
+ where: { id: session.user.identityId },
+ select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
+ })
+ : null;
+ // SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
+ if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
+ await signOut({ redirectTo: "/login" });
+ }
+ if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
+ return { kind: "inactive" };
+ }
+ if (identity.mustChangePassword) redirect("/change-password");
+
+ // SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
+ // noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb der Shells).
+ if (!identity.mfaEnrolledAt) {
+ const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
+ if (resolveMfaRequired(settings?.securityPolicy)) {
+ const passkeys = session.user.identityId
+ ? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
+ : 0;
+ if (passkeys === 0) redirect("/enroll-mfa");
+ }
+ }
+
+ return { kind: "ok", session, identity: { uiLocale: identity.uiLocale } };
+}
+
+/**
+ * Startseite nach dem Login (ARCHITEKTUR §5): Feldrollen (Einsatz ausführen, keine
+ * Gesamtsicht auf Aufträge) landen in der Mobile-App `/m`, alle anderen im Backoffice.
+ * Reiner UX-Komfort — die Zielseiten prüfen Rechte selbst.
+ */
+export function landingPath(session: Session | null): string {
+ const perms = session?.user?.permissions ?? [];
+ return perms.includes("field:execute") && !perms.includes("work_order:read_all") ? "/m" : "/dashboard";
+}