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.

-
{ "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5"> - -
-
-
- ); - } - 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 ( +
+ +
+
+ +
+
+

{t("voice.title")}

+ +
+ + {order.voiceNotes.length > 0 && ( +
    + {order.voiceNotes.map((v) => ( +
  • +

    + {t("voice.title")} · {fmtDateTime(v.recordedAt, locale)} + {v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""} +

    +
  • + ))} +
+ )} + + {order.notes.length === 0 ? ( +

{t("notes.empty")}

+ ) : ( +
    + {order.notes.map((n) => ( +
  • +

    + {t(`notes.kind.${n.kind}`)} · {fmtDateTime(n.createdAt, locale)} +

    +

    {n.text}

    +
  • + ))} +
+ )} +
+
+ ); +} 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 ( +
+ {phone && ( + + + {labels.call} + + )} + {email && ( + + + {labels.mail} + + )} +
+ ); +} + +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 && ( + + )} + + {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.comment + +
    +

    + {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")}

+ + {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 ( +
+
+ + + +
+ {/* Slot für L6: aus src/components/notifications/bell.tsx */} + +
+
+
{children}
+ +
+ ); +} 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.

+
{ "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5"> + +
+
+
+ ); +} 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 ( + + ); +} 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 ( +
  • + + {editing ? ( +
    +