L4 Einsatz mobil: Mobile Shell und Einsatz-Oberfläche

- /m in eigene Route-Group (field)/m verschoben (emergency-Platzhalter mit),
  Zugriffsprüfungen aus (app)/layout.tsx nach server/app-access.ts extrahiert
  und von Backoffice- und Mobile-Shell gemeinsam genutzt
- Mobile Shell mit Bottom-Nav (Heute · Aufträge · Notdienst · Sync · Profil)
  und Online/Offline-Badge; Startseite rollenabhängig (Feldrollen → /m),
  Login-Default-Redirect auf /
- Heute, Auftragsliste mit Tabs, Auftragsdetail mit einer Primäraktion je
  Zustand, Unterseiten Fotos (Kamera, Kompression, Upload-Fortschritt),
  Notizen + Sprachaufnahme, Material mit Stepper, Checkliste, Zeiten mit
  Korrektur, Profil; Sync-Platzhalter für L7
- Client-Wrapper submitOp (lib/field/client-ops.ts), Upload mit Fortschritt,
  Bildkompression, Formatierung; Texte in messages/{de,en}/field.json

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:30:03 +02:00
co-authored by Claude Opus 5
parent 952e74cddd
commit 95f41b29f5
42 changed files with 3186 additions and 66 deletions
+298
View File
@@ -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"
}
}
+298
View File
@@ -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"
}
}
+9 -56
View File
@@ -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 (
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
</form>
</div>
</main>
);
}
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 <AccountInactiveNotice />;
const { session, identity } = access;
const t = await getTranslations("nav");
const tc = await getTranslations("common");
-5
View File
@@ -1,5 +0,0 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="field" />;
}
@@ -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 (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("checklist.title")} />
<div className="space-y-3 px-4">
{order.checklistItems.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("checklist.empty")}</p>
) : (
<>
<p className="text-[15px] font-semibold">{t("detail.checklistProgress", { done, total: order.checklistItems.length })}</p>
<ul className="space-y-3">
{order.checklistItems.map((item) => (
<ChecklistItemRow key={item.id} workOrderId={order.id} item={item} />
))}
</ul>
</>
)}
</div>
</main>
);
}
@@ -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;
}
}
@@ -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 (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("materials.title")} />
<div className="space-y-4 px-4">
{order.materialPlans.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("materials.noPlan")}</p>
) : (
<ul className="space-y-3">
{order.materialPlans.map((p) => {
const usage = order.materialUsages.find((u) => u.materialPlanId === p.id);
return (
<MaterialPlanItem
key={p.id}
workOrderId={order.id}
plan={{ id: p.id, name: p.name, articleNumber: p.articleNumber, plannedQuantity: Number(p.plannedQuantity), unit: p.unit, notes: p.notes }}
usage={usage ? { usageStatus: usage.usageStatus, quantity: Number(usage.actualQuantity), unit: usage.unit, deviationReason: usage.deviationReason } : null}
/>
);
})}
</ul>
)}
<h2 className="pt-2 text-[20px]">{t("materials.additionalTitle")}</h2>
{additional.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("materials.additionalEmpty")}</p>
) : (
<ul className="space-y-2">
{additional.map((u) => (
<li key={u.id} className={card}>
<p className="text-[16px] font-semibold">{u.name}</p>
<p className="text-[14px]">{t("materials.recorded", { quantity: String(Number(u.actualQuantity)).replace(".", ","), unit: u.unit })}</p>
{u.deviationReason && <p className="text-[14px] text-muted-foreground">{u.deviationReason}</p>}
</li>
))}
</ul>
)}
<AdditionalMaterialForm workOrderId={order.id} />
</div>
</main>
);
}
@@ -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 (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("notes.title")} />
<div className="space-y-4 px-4">
<section className={card}>
<NoteForm workOrderId={order.id} />
</section>
<section className={card}>
<h2 className="mb-3 text-[17px]">{t("voice.title")}</h2>
<VoiceRecorder workOrderId={order.id} />
</section>
{order.voiceNotes.length > 0 && (
<ul className="space-y-3">
{order.voiceNotes.map((v) => (
<li key={v.id} className={card}>
<p className="text-[13px] text-muted-foreground">
{t("voice.title")} · {fmtDateTime(v.recordedAt, locale)}
{v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}
</p>
<audio controls preload="none" src={`/api/v1/field/documents/${v.documentId}`} className="mt-2 w-full" />
<p className="mt-2 text-[13px] font-semibold">{t(`voice.status.${v.transcriptionStatus}`)}</p>
{v.transcript && <p className="mt-1 whitespace-pre-line text-[15px]">{v.transcript}</p>}
</li>
))}
</ul>
)}
{order.notes.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("notes.empty")}</p>
) : (
<ul className="space-y-3">
{order.notes.map((n) => (
<li key={n.id} className={card}>
<p className="text-[13px] text-muted-foreground">
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {fmtDateTime(n.createdAt, locale)}
</p>
<p className="mt-1 whitespace-pre-line text-[15px]">{n.text}</p>
</li>
))}
</ul>
)}
</div>
</main>
);
}
@@ -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 = (
<div className="flex min-h-12 items-center gap-2.5">
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="flex-1">
<h2 className="text-[17px]">{title}</h2>
{summary && <p className="text-[14px] text-muted-foreground">{summary}</p>}
</div>
{href && <ChevronRight className="size-5 text-muted-foreground" aria-hidden />}
</div>
);
return (
<section className={card}>
{href ? (
<Link href={href} className="block">
{head}
</Link>
) : (
head
)}
{children && <div className="mt-2 space-y-2 text-[15px]">{children}</div>}
</section>
);
}
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 (
<div className="flex gap-2">
{phone && (
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={cls}>
<Phone className="size-4.5" aria-hidden />
{labels.call}
</a>
)}
{email && (
<a href={`mailto:${email}`} className={cls}>
<Mail className="size-4.5" aria-hidden />
{labels.mail}
</a>
)}
</div>
);
}
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 (
<div className={cn("flex items-start gap-2.5 rounded-xl px-3.5 py-3", toneCls)}>
<Icon className="mt-0.5 size-5 shrink-0" aria-hidden />
<p>
<span className="block text-[13px] font-bold uppercase tracking-wide">{label}</span>
<span className="whitespace-pre-line">{text}</span>
</p>
</div>
);
}
/** `/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 (
<main className="space-y-3 p-4">
<Link href="/m/orders" className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("detail.back")}
</Link>
<section className={cn(card, "border-l-4", toneClasses(STATUS_GROUP[order.status]).edge)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[14px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status} large />
</div>
<h1 className="mt-2 text-[22px] leading-snug">{order.title}</h1>
<p className="text-[16px] font-semibold">{customerDisplayName(order.customer)}</p>
<p className="mt-2 flex items-center gap-2 text-[15px]">
<Clock className="size-4.5 text-muted-foreground" aria-hidden />
{fmtWindow(order.plannedStart, order.plannedEnd, locale) ?? t("card.noDate")}
</p>
{editable && (
<div className="mt-4">
<PrimaryAction workOrderId={order.id} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} />
</div>
)}
</section>
{editable && (
<nav aria-label={t("detail.order")} className="grid grid-cols-5 gap-2">
<QuickPhotoButton workOrderId={order.id} label={t("detail.quick.photo")} />
{quick.map((q) => (
<Link key={q.href} href={q.href} className="flex min-h-18 flex-col items-center justify-center gap-1 rounded-xl border bg-card px-1 text-[13px] font-semibold text-primary">
<q.icon className="size-6" aria-hidden />
{q.label}
</Link>
))}
</nav>
)}
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
{order.site && (
<Section title={t("detail.site")} icon={Building2}>
<p className="font-semibold">{order.site.name}</p>
{address && <p>{address}</p>}
{route && (
<a href={route} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary">
<Navigation className="size-4.5" aria-hidden />
{t("detail.route")}
</a>
)}
{order.site.accessNotes && <Notice icon={KeyRound} label={t("detail.access")} text={order.site.accessNotes} tone="warn" />}
{order.site.parkingNotes && <Notice icon={SquareParking} label={t("detail.parking")} text={order.site.parkingNotes} tone="info" />}
{order.site.safetyNotes && <Notice icon={ShieldAlert} label={t("detail.safety")} text={order.site.safetyNotes} tone="risk" />}
{order.site.technicalNotes && <Notice icon={Wrench} label={t("detail.technical")} text={order.site.technicalNotes} tone="info" />}
{order.site.onSiteContact && (
<p>
<span className="text-muted-foreground">{t("detail.onSiteContact")}: </span>
{order.site.onSiteContact}
</p>
)}
<ContactButtons phone={order.site.phone} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
</Section>
)}
<Section title={t("detail.customer")} icon={User}>
<p className="font-semibold">{customerDisplayName(order.customer)}</p>
{!order.site && address && <p>{address}</p>}
<ContactButtons phone={order.customer.mobile ?? order.customer.phone} email={order.customer.email} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
{contact && (
<div className="mt-2 border-t pt-2">
<p className="text-[13px] text-muted-foreground">{t("detail.contact")}</p>
<p className="font-semibold">
{contact.name}
{contact.role && <span className="font-normal text-muted-foreground"> · {contact.role}</span>}
</p>
<ContactButtons phone={contact.mobile ?? contact.phone} email={contact.email} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
</div>
)}
</Section>
<Section title={t("detail.order")} icon={FileText}>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{order.orderType && (
<>
<dt className="text-muted-foreground">{t("detail.orderType")}</dt>
<dd>{order.orderType.name}</dd>
</>
)}
{order.externalOrderNumber && (
<>
<dt className="text-muted-foreground">{t("detail.externalNumber")}</dt>
<dd>{order.externalOrderNumber}</dd>
</>
)}
{order.team && (
<>
<dt className="text-muted-foreground">{t("detail.team")}</dt>
<dd>{order.team.name}</dd>
</>
)}
</dl>
{order.description && <p className="whitespace-pre-line">{order.description}</p>}
{order.scope && (
<div>
<p className="text-[13px] font-semibold text-muted-foreground">{t("detail.scope")}</p>
<p className="whitespace-pre-line">{order.scope}</p>
</div>
)}
</Section>
<Section title={t("detail.documents")} icon={FileText}>
{order.documents.length === 0 ? (
<p className="text-muted-foreground">{t("detail.noDocuments")}</p>
) : (
<ul className="divide-y">
{order.documents.map((d) => (
<li key={d.id}>
<a href={`/api/v1/field/documents/${d.id}`} target="_blank" rel="noopener" className="flex min-h-12 items-center gap-2 py-1.5">
<FileText className="size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">
{d.title ?? d.fileName}
{d.source === "site" && <span className="ml-1.5 text-[12.5px] text-muted-foreground">({t("detail.siteDocument")})</span>}
</span>
<span className="text-[13px] font-semibold text-primary">{t("detail.openDocument")}</span>
</a>
</li>
))}
</ul>
)}
</Section>
{order.site && (
<Section title={t("detail.history")} icon={History}>
{order.siteHistory.length === 0 ? (
<p className="text-muted-foreground">{t("detail.noHistory")}</p>
) : (
<ul className="divide-y">
{order.siteHistory.map((h) => (
<li key={h.reportId} className="flex min-h-12 items-center gap-2 py-1.5">
<span className="flex-1">
<span className="block font-semibold">
{t(`detail.reportType.${h.reportType}`)} · {fmtDate(h.reportDate, locale)}
</span>
<span className="text-[13px] text-muted-foreground">
{h.workOrderNumber} · {h.workOrderTitle}
</span>
</span>
{h.pdfDocumentId && (
<a href={`/api/v1/field/documents/${h.pdfDocumentId}`} target="_blank" rel="noopener" className="inline-flex min-h-12 items-center px-2 text-[13px] font-semibold text-primary">
{t("detail.openDocument")}
</a>
)}
</li>
))}
</ul>
)}
</Section>
)}
<Section
title={t("detail.checklist")}
icon={ListChecks}
href={`${base}/checklist`}
summary={order.checklistItems.length ? t("detail.checklistProgress", { done: checklistDone, total: order.checklistItems.length }) : t("checklist.empty")}
/>
<Section
title={t("detail.materials")}
icon={Package}
href={`${base}/materials`}
summary={order.materialPlans.length ? t("detail.materialsProgress", { done: plannedDone, total: order.materialPlans.length }) : t("detail.materialsNone")}
/>
<Section
title={t("detail.photos")}
icon={FileText}
href={`${base}/photos`}
summary={
order.photoRequirements.length
? t("detail.photosProgress", { count: order.photos.length, done: requirementsDone, total: order.photoRequirements.length })
: t("detail.photosCount", { count: order.photos.length })
}
/>
<Section title={t("detail.notes")} icon={StickyNote} href={`${base}/notes`} summary={t("detail.notesCount", { count: order.notes.length + order.voiceNotes.length })} />
<Section title={t("detail.time")} icon={Clock} href={`${base}/time`} summary={t("detail.myTime", { duration: fmtDuration(myWorkSeconds) })} />
</main>
);
}
@@ -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 (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("photos.title")} />
<div className="space-y-4 px-4">
<section className={card}>
<PhotoCapture
workOrderId={order.id}
requirements={order.photoRequirements.map((r) => ({ id: r.id, label: r.label }))}
checklistItems={order.checklistItems.map((c) => ({ id: c.id, label: c.label }))}
/>
</section>
{order.photoRequirements.length > 0 && (
<section className={card}>
<h2 className="mb-2 text-[17px]">{t("photos.requirements")}</h2>
<ul className="space-y-1.5">
{order.photoRequirements.map((r) => {
const done = r._count.photos > 0;
return (
<li key={r.id} className="flex min-h-10 items-center gap-2 text-[15px]">
{done ? <CircleCheck className="size-5 text-[var(--ok)]" aria-hidden /> : <CircleDot className="size-5 text-[var(--warn)]" aria-hidden />}
<span className="flex-1">{r.label}</span>
<span className="text-[13px] font-semibold">{done ? t("photos.done") : t("photos.missing")}</span>
</li>
);
})}
</ul>
</section>
)}
{order.photos.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("photos.empty")}</p>
) : (
<ul className="grid grid-cols-2 gap-3">
{order.photos.map((p) => (
<li key={p.id} className="overflow-hidden rounded-xl border bg-card">
<a href={`/api/v1/field/documents/${p.documentId}`} target="_blank" rel="noopener">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/api/v1/field/documents/${p.documentId}?variant=preview`} alt={p.comment ?? t("photos.preview")} loading="lazy" className="aspect-square w-full bg-muted object-cover" />
</a>
<div className="space-y-0.5 p-2 text-[12.5px]">
<p className="font-semibold">
{p.phase ? t(`photos.phase.${p.phase}`) : "—"}
{p.photoRequirementId && requirementLabel.get(p.photoRequirementId) ? ` · ${requirementLabel.get(p.photoRequirementId)}` : ""}
</p>
{p.comment && <p className="line-clamp-2">{p.comment}</p>}
<p className="text-muted-foreground">{fmtDateTime(p.takenAt, locale)}</p>
</div>
</li>
))}
</ul>
)}
</div>
</main>
);
}
@@ -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 (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} />
<div className="space-y-3 px-4">
<p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p>
{order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>}
{order.workSessions.map((s) => (
<section key={s.id} className={card}>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-[17px]">{s.user.name}</h2>
<span className="text-[13px] font-semibold">
{t(`time.session.${s.status}`)}
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
</span>
</div>
<p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p>
<ul className="mt-2 divide-y">
{s.entries.map((e) => (
<li key={e.id} className="space-y-2 py-2.5">
<div className="flex items-baseline justify-between gap-2 text-[15px]">
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
<span>
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
</span>
</div>
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
{canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
</li>
))}
</ul>
</section>
))}
</div>
</main>
);
}
+46
View File
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
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 (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("orders.title")}</h1>
<nav aria-label={t("orders.title")} className="-mx-4 overflow-x-auto px-4">
<ul className="flex gap-2">
{ORDER_TABS.map((key) => (
<li key={key} className="shrink-0">
<Link href={`/m/orders?tab=${key}`} aria-current={tab === key ? "page" : undefined} className={cn(chip(tab === key), "px-4")}>
{t(`orders.tabs.${key}`)}
</Link>
</li>
))}
</ul>
</nav>
{orders.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("orders.empty")}</p>
) : (
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id}>
<OrderCard order={o} />
</li>
))}
</ul>
)}
</main>
);
}
+41
View File
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
return (
<main className="space-y-4 p-4">
<div>
<p className="text-[14px] text-muted-foreground">{t("today.greeting", { name: me?.name ?? "" })}</p>
<h1 className="text-[26px]">{t("today.title")}</h1>
<p className="text-[15px] font-semibold">{t("today.count", { count: orders.length })}</p>
</div>
{orders.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("today.empty")}</p>
) : (
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id}>
<OrderCard order={o} />
</li>
))}
</ul>
)}
<Link href="/m/orders" className={btnSecondary}>
{t("today.allOrders")}
<ChevronRight className="size-5" aria-hidden />
</Link>
</main>
);
}
+73
View File
@@ -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 (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1>
<section className={card}>
<p className="text-[20px] font-semibold">{me?.name}</p>
<p className="text-[14px] text-muted-foreground">{me?.email}</p>
<dl className="mt-2 divide-y text-[15px]">
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("tenant")}</dt>
<dd>{me?.tenant.name}</dd>
</div>
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("teams")}</dt>
<dd>{teams.length ? teams.map((tm) => tm.name).join(", ") : t("noTeam")}</dd>
</div>
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("roles")}</dt>
<dd>{me?.userRoles.map((r) => r.role.name).join(", ")}</dd>
</div>
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("language")}</dt>
<dd className="pt-1">
<UiLocaleSwitcher current={identity?.uiLocale ?? "de"} />
</dd>
</div>
</dl>
</section>
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
<Link href="/dashboard" className={btnSecondary}>
{t("backoffice")}
</Link>
)}
<form action={logout}>
<button type="submit" className={btnSecondary}>
<LogOut className="size-5" aria-hidden />
{t("logout")}
</button>
</form>
</main>
);
}
+34
View File
@@ -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 <AccountInactiveNotice />;
const t = await getTranslations("field.nav");
return (
<div className="flex min-h-screen flex-1 flex-col bg-background">
<header className="sticky top-0 z-20 flex h-14 items-center justify-between gap-3 border-b bg-card px-4 pt-[env(safe-area-inset-top)]">
<Link href="/m" aria-label={t("today")} className="flex min-h-12 items-center">
<CraftviaLogo variant="horizontal" height={26} />
</Link>
<div className="flex items-center gap-2">
{/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */}
<OnlineBadge />
</div>
</header>
<div className="mx-auto w-full max-w-xl flex-1 pb-28">{children}</div>
<BottomNav />
</div>
);
}
+23
View File
@@ -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 (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1>
<section className={`${card} space-y-3`}>
<p className="text-[13px] font-semibold text-muted-foreground">{t("status")}</p>
<OnlineBadge large />
<p className="text-[15px]">{t("immediate")}</p>
<p className="text-[14px] text-muted-foreground">{t("offlineHint")}</p>
</section>
</main>
);
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)}` : ""}`);
+5 -2
View File
@@ -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()));
}
@@ -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 (
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
</form>
</div>
</main>
);
}
+47
View File
@@ -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 (
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
<ul className="mx-auto grid max-w-xl grid-cols-5">
{ITEMS.map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1 text-[12px] font-semibold",
active ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
<span className={cn("grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}>
<item.icon className="size-5.5" aria-hidden />
</span>
{t(item.key)}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
+89
View File
@@ -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<string | null>(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 (
<li className={cn(card, "space-y-3")}>
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={busy}
onClick={() => send(!checked)}
className="flex min-h-14 w-full items-center gap-3 text-left"
>
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border-2", checked ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "border-input bg-card")}>
{checked && <Check className="size-6" aria-hidden />}
</span>
<span className="flex-1">
<span className="block text-[16px] font-semibold leading-snug">{item.label}</span>
<span className="mt-1 flex flex-wrap gap-1.5 text-[12.5px]">
<span className={cn("font-semibold", checked ? "text-[var(--ok)]" : "text-muted-foreground")}>{checked ? t("checklist.done") : t("checklist.open")}</span>
{item.required && <span className="rounded-full bg-muted px-2 font-semibold">{t("checklist.required")}</span>}
{item.requiresPhoto && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 font-semibold">
<Camera className="size-3.5" aria-hidden />
{t("checklist.photo")}
</span>
)}
</span>
</span>
</button>
{editing ? (
<div className="space-y-2">
<textarea rows={2} maxLength={2000} aria-label={t("checklist.comment")} className={cn(inputClass, "py-3")} value={comment} onChange={(e) => setComment(e.target.value)} />
<button type="button" className={btnSecondary} disabled={busy} onClick={() => send(checked, comment)}>
{t("checklist.saveComment")}
</button>
</div>
) : (
<button type="button" onClick={() => setEditing(true)} className="min-h-12 w-full rounded-xl px-1 text-left text-[14px] text-muted-foreground">
{comment ? comment : `${t("checklist.comment")} …`}
</button>
)}
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
</li>
);
}
+203
View File
@@ -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 && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
{saved && !error && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{savedLabel}
</p>
)}
</>
);
}
/** 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<MaterialUsageStatus>(usage?.usageStatus ?? "fully_used");
const [quantity, setQuantity] = useState<number>(usage?.quantity ?? plan.plannedQuantity);
const [reason, setReason] = useState(usage?.deviationReason ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<li className={cn(card, "space-y-3")}>
<div>
<p className="text-[16px] font-semibold">{plan.name}</p>
{plan.articleNumber && <p className="font-mono text-[13px] text-muted-foreground">{plan.articleNumber}</p>}
<p className="text-[14px] text-muted-foreground">{t("materials.planned", { quantity: fmtQty(plan.plannedQuantity), unit: plan.unit })}</p>
{plan.notes && <p className="mt-1 text-[14px]">{plan.notes}</p>}
<p className="mt-1 text-[13px] font-semibold">
{usage ? t("materials.recorded", { quantity: fmtQty(usage.quantity), unit: usage.unit }) : t("materials.open")}
</p>
</div>
<div className="grid grid-cols-3 gap-2">
{PLAN_STATUSES.map((s) => (
<button key={s} type="button" aria-pressed={status === s} className={cn(chip(status === s), "px-2 text-[13px]")} onClick={() => choose(s)}>
{t(`materials.usage.${s}`)}
</button>
))}
</div>
{status !== "not_used" && (
<Stepper
value={quantity}
onChange={(v) => {
setQuantity(v);
setSaved(false);
}}
label={`${t("materials.quantity")} (${plan.unit})`}
decreaseLabel={t("materials.decrease")}
increaseLabel={t("materials.increase")}
/>
)}
{deviates && (
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.reason")}</span>
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
)}
<Feedback error={error} saved={saved} savedLabel={t("materials.saved")} />
<button type="button" className={btnPrimary} disabled={busy} onClick={save}>
{busy ? t("action.saving") : t("materials.save")}
</button>
</li>
);
}
/** 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<string>(UNIT_SUGGESTIONS[0]);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<form onSubmit={submit} className={cn(card, "space-y-3")}>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.name")}</span>
<input className={inputClass} required maxLength={200} value={name} onChange={(e) => { setName(e.target.value); setSaved(false); }} />
</label>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.articleNumber")}</span>
<input className={inputClass} maxLength={100} value={articleNumber} onChange={(e) => setArticleNumber(e.target.value)} />
</label>
<Stepper value={quantity} onChange={setQuantity} label={t("materials.quantity")} decreaseLabel={t("materials.decrease")} increaseLabel={t("materials.increase")} />
<fieldset>
<legend className="mb-2 text-[14px] font-semibold">{t("materials.unit")}</legend>
<div className="flex flex-wrap gap-2">
{UNIT_SUGGESTIONS.slice(0, 7).map((u) => (
<button key={u} type="button" aria-pressed={unit === u} className={cn(chip(unit === u), "min-w-12")} onClick={() => setUnit(u)}>
{u}
</button>
))}
</div>
<input className={cn(inputClass, "mt-2")} list="field-unit-suggestions" maxLength={20} aria-label={t("materials.unit")} value={unit} onChange={(e) => setUnit(e.target.value)} />
<datalist id="field-unit-suggestions">
{UNIT_SUGGESTIONS.map((u) => (
<option key={u} value={u} />
))}
</datalist>
</fieldset>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.additionalReason")}</span>
<textarea rows={2} required maxLength={2000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<Feedback error={error} saved={saved} savedLabel={t("materials.saved")} />
<button type="submit" className={btnPrimary} disabled={busy}>
{busy ? t("action.saving") : t("materials.add")}
</button>
</form>
);
}
+118
View File
@@ -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<Draft>({ kind: "work_done", text: "", clientId: "" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<Draft>) {
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 (
<form onSubmit={submit} className="space-y-4">
<fieldset>
<legend className="mb-2 text-[14px] font-semibold">{t("notes.kindLabel")}</legend>
<div className="grid grid-cols-2 gap-2">
{NOTE_KINDS.map((k) => (
<button key={k} type="button" aria-pressed={draft.kind === k} className={chip(draft.kind === k)} onClick={() => update({ kind: k })}>
{t(`notes.kind.${k}`)}
</button>
))}
</div>
</fieldset>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("notes.text")}</span>
<textarea
rows={4}
maxLength={10000}
required
className={cn(inputClass, "py-3")}
placeholder={t("notes.placeholder")}
value={draft.text}
onChange={(e) => update({ text: e.target.value })}
/>
</label>
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
{saved && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("notes.saved")}
</p>
)}
<button type="submit" className={btnPrimary} disabled={busy || !draft.text.trim()}>
{busy ? t("action.saving") : t("notes.save")}
</button>
</form>
);
}
+42
View File
@@ -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 (
<span
role="status"
aria-live="polite"
className={cn(
"inline-flex items-center gap-1.5 rounded-full font-semibold",
large ? "px-3.5 py-2 text-[15px]" : "px-2.5 py-1 text-[12.5px]",
online
? "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]"
: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]",
)}
>
<Icon className={large ? "size-5" : "size-4"} aria-hidden />
{online ? t("online") : t("offline")}
</span>
);
}
+61
View File
@@ -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 (
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup).edge)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status} />
</div>
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
<p className="mt-0.5 text-[15px] font-semibold text-foreground">{order.customerName}</p>
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
<p className="mt-1.5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
<Siren className="size-4" aria-hidden />
{order.isEmergency ? t("emergency") : order.priority === "urgent" ? t("urgent") : t("high")}
</p>
)}
<dl className="mt-3 space-y-2 text-[15px]">
<div className="flex items-start gap-2">
<Clock className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<dd>{window ?? t("noDate")}</dd>
</div>
{order.address && (
<div className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<dd className="flex-1">
{order.siteName && <span className="block text-[13px] text-muted-foreground">{order.siteName}</span>}
{order.address}
</dd>
{order.mapsUrl && (
<a
href={order.mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-12 min-w-12 items-center justify-center gap-1 rounded-xl border px-3 text-[13px] font-semibold text-primary"
>
<Navigation className="size-4" aria-hidden />
{t("route")}
</a>
)}
</div>
)}
</dl>
<Link href={`/m/orders/${order.id}`} className={cn(btnPrimary, "mt-4")}>
{t("open")}
<ChevronRight className="size-5" aria-hidden />
</Link>
</article>
);
}
+287
View File
@@ -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<Progress>({ stage: "idle" });
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const uploaded = useRef<{ file: File; documentId: string } | null>(null);
async function save(file: File, meta: Meta): Promise<boolean> {
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 (
<div role="status" aria-live="polite" className="space-y-1.5">
<p className="flex items-center gap-2 text-[14px] font-semibold">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{label}
</p>
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${percent}%` }} />
</div>
</div>
);
}
/** 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 (
<div className="contents">
<label className={cn("flex min-h-18 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl bg-cta px-2 text-[13px] font-semibold text-cta-foreground", busy && "opacity-60")}>
{busy ? <LoaderCircle className="size-6 animate-spin" aria-hidden /> : <Camera className="size-6" aria-hidden />}
{label}
<input
type="file"
accept="image/*"
capture="environment"
className="sr-only"
disabled={busy}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) setDone(await save(file, { phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false }));
}}
/>
</label>
{(busy || error || done) && (
<div className="col-span-full">
<ProgressBar progress={progress} label={progressLabel} />
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
{done && !busy && !error && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("saved")}
</p>
)}
</div>
)}
</div>
);
}
/** 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<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [meta, setMeta] = useState<Meta>({ 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<HTMLInputElement>) => {
const f = e.target.files?.[0];
e.target.value = "";
if (f) showFile(f);
};
if (!file) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2.5">
<label className={cn(btnPrimary, "cursor-pointer")}>
<Camera className="size-5" aria-hidden />
{t("camera")}
<input type="file" accept="image/*" capture="environment" className="sr-only" onChange={pick} />
</label>
<label className={cn(btnSecondary, "min-h-14 cursor-pointer")}>
<ImageIcon className="size-5" aria-hidden />
{t("gallery")}
<input type="file" accept="image/*" className="sr-only" onChange={pick} />
</label>
</div>
{saved && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("saved")}
</p>
)}
</div>
);
}
return (
<form
className="space-y-4"
onSubmit={async (e) => {
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
<img src={previewUrl} alt={t("preview")} className="max-h-72 w-full rounded-xl bg-muted object-contain" />
)}
<fieldset>
<legend className="mb-2 text-[14px] font-semibold">{t("phaseLabel")}</legend>
<div className="grid grid-cols-3 gap-2">
{PHOTO_PHASES.map((p) => (
<button key={p} type="button" aria-pressed={meta.phase === p} className={chip(meta.phase === p)} onClick={() => setMeta({ ...meta, phase: p })}>
{t(`phase.${p}`)}
</button>
))}
</div>
</fieldset>
{requirements.length > 0 && (
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("requirement")}</span>
<select className={inputClass} value={meta.photoRequirementId ?? ""} onChange={(e) => setMeta({ ...meta, photoRequirementId: e.target.value || null })}>
<option value="">{t("none")}</option>
{requirements.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</label>
)}
{checklistItems.length > 0 && (
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("checklistItem")}</span>
<select className={inputClass} value={meta.checklistItemId ?? ""} onChange={(e) => setMeta({ ...meta, checklistItemId: e.target.value || null })}>
<option value="">{t("none")}</option>
{checklistItems.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</label>
)}
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("comment")}</span>
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={meta.comment} onChange={(e) => setMeta({ ...meta, comment: e.target.value })} />
</label>
<label className="flex min-h-12 items-center gap-3 text-[15px]">
<input type="checkbox" className="size-6 accent-[var(--primary)]" checked={meta.withLocation} onChange={(e) => setMeta({ ...meta, withLocation: e.target.checked })} />
{t("location")}
</label>
<ProgressBar progress={progress} label={label} />
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
<button type="submit" className={btnPrimary} disabled={busy}>
<Upload className="size-5" aria-hidden />
{t("save")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => showFile(null)}>
{t("discard")}
</button>
</form>
);
}
+163
View File
@@ -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<ActionKey | null>(null);
const [error, setError] = useState<string | null>(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 <p className="text-[15px] text-muted-foreground">{["technically_completed", "signature_pending"].includes(status) ? t("action.reportNext") : t("action.nothing")}</p>;
}
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 (
<button type="button" onClick={() => run(action)} disabled={disabled} className={variant === "primary" ? btnPrimary : btnSecondary}>
<Icon className={busy === action ? "size-5 animate-spin" : "size-5"} aria-hidden />
{busy === action ? t("action.saving") : action === "complete" && confirm ? t("action.confirmComplete") : t(`action.${action}`)}
</button>
);
};
return (
<div className="space-y-2.5">
{renderButton(primary, "primary")}
{secondary && renderButton(secondary, "secondary")}
{confirm && (
<div className={noticeWarn}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
<span>
{t("action.confirmHint")}{" "}
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(false)}>
{t("photos.discard")}
</button>
</span>
</div>
)}
{completeBlocked && (primary === "complete" || secondary === "complete") && (
<div className={noticeWarn} role="note">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
<div>
<p className="font-semibold">{t("action.blocked")}</p>
<ul className="mt-1 list-disc pl-4">
{blockers.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</div>
</div>
)}
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
</div>
);
}
+33
View File
@@ -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<StatusGroup, LucideIcon> = {
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 (
<span className={cn("inline-flex items-center gap-1.5 rounded-full font-semibold", large ? "px-3 py-1.5 text-[14px]" : "px-2.5 py-1 text-[12.5px]", toneClasses(group).badge)}>
<Icon className={large ? "size-4.5" : "size-4"} aria-hidden />
{groupLabel}
{detail !== groupLabel && <span className="font-normal">· {detail}</span>}
</span>
);
}
+49
View File
@@ -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 (
<div className="flex items-center gap-2">
<button type="button" className={btn} aria-label={decreaseLabel} disabled={value <= min} onClick={() => onChange(Math.max(min, round(value - step)))}>
<Minus className="size-6" aria-hidden />
</button>
<input
type="text"
inputMode="decimal"
aria-label={label}
className={cn(inputClass, "min-h-14 text-center text-lg font-semibold")}
value={String(value).replace(".", ",")}
onChange={(e) => {
const n = Number(e.target.value.replace(",", "."));
if (Number.isFinite(n) && n >= min) onChange(round(n));
else if (e.target.value.trim() === "") onChange(min);
}}
/>
<button type="button" className={btn} aria-label={increaseLabel} onClick={() => onChange(round(value + step))}>
<Plus className="size-6" aria-hidden />
</button>
</div>
);
}
+20
View File
@@ -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 (
<div className="px-4 pt-3">
<Link href={`/m/orders/${workOrderId}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("backToOrder")}
</Link>
<p className="mt-1 font-mono text-[13px] text-muted-foreground">
{number} · {title}
</p>
<h1 className="text-[24px]">{section}</h1>
</div>
);
}
@@ -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 <input type="datetime-local"> 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<string | null>(null);
if (!open) {
return (
<button
type="button"
className={cn(btnSecondary, "min-h-12")}
onClick={() => {
// 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")}
</button>
);
}
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 (
<form onSubmit={submit} className="space-y-3 rounded-xl bg-muted p-3">
<div className="grid grid-cols-1 gap-3">
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.start")}</span>
<input type="datetime-local" required className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.end")}</span>
<input type="datetime-local" className={inputClass} value={end} min={start} onChange={(e) => setEnd(e.target.value)} />
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.reason")}</span>
<textarea rows={2} required minLength={3} maxLength={1000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
<span className="mt-1 block text-[13px] text-muted-foreground">{t("time.reasonHint")}</span>
</label>
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
<button type="submit" className={btnPrimary} disabled={busy}>
{busy ? t("action.saving") : t("time.save")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setOpen(false)}>
{t("photos.discard")}
</button>
</form>
);
}
+40
View File
@@ -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";
+165
View File
@@ -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<string | null>(null);
const [saved, setSaved] = useState(false);
const [percent, setPercent] = useState(0);
const recorder = useRef<MediaRecorder | null>(null);
const timer = useRef<ReturnType<typeof setInterval> | 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 <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>;
return (
<div className="space-y-3">
{state === "idle" && (
<button type="button" className={btnSecondary} onClick={start}>
<Mic className="size-5" aria-hidden />
{t("voice.record")}
</button>
)}
{state === "recording" && (
<>
<p role="status" aria-live="polite" className="flex items-center gap-2 text-[15px] font-semibold text-[var(--risk)]">
<span className="size-3 animate-pulse rounded-full bg-[var(--risk)]" aria-hidden />
{t("voice.recording", { time: clock(seconds) })}
</p>
<button type="button" className={btnPrimary} onClick={stop}>
<Square className="size-5" aria-hidden />
{t("voice.stop")}
</button>
</>
)}
{(state === "recorded" || state === "saving") && clip && (
<>
<audio controls src={clip.url} className="w-full" />
{state === "saving" && (
<p role="status" className="flex items-center gap-2 text-[14px] font-semibold">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("photos.uploading", { percent })}
</p>
)}
<button type="button" className={btnPrimary} onClick={save} disabled={state === "saving"}>
<Upload className="size-5" aria-hidden />
{t("voice.save")}
</button>
<button type="button" className={btnSecondary} onClick={() => { setClip(null); setState("idle"); }} disabled={state === "saving"}>
{t("voice.discard")}
</button>
</>
)}
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
{saved && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("voice.saved")}
</p>
)}
</div>
);
}
+78
View File
@@ -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<SyncOpResult>` stable.
*/
export type ClientOp<T extends SyncOpType = SyncOpType> = {
opType: T;
payload: OpPayload<T>;
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<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> {
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);
}
+42
View File
@@ -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));
}
+81
View File
@@ -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 <img> 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<Drawable> {
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 <img> decoding (e.g. older Safari)
}
}
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((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<Blob> {
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 },
);
});
}
+48
View File
@@ -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<UploadResult> {
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);
});
}
+72
View File
@@ -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<AppAccess> {
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";
}