- /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>
164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|