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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user