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
+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>
);
}