Laufende-Uhr-Leiste auf allen /m-Seiten, Start/Pause direkt auf den Auftragskarten, Auto-Wechsel-Dialog, Auftragsdetail mit Pause / Für heute beenden / Abschließen-Link und Segmentwechseln, /m/time (Tagesübersicht, Nachtragen, Korrektur vorschlagen), /m/approvals für Teamleiter, /work-orders/time-approvals mit Sammelfreigabe, Zeiten-Tab mit Badges und Inline-Freigabe, Dashboard-Kachel, Texte de/en. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
4.2 KiB
TypeScript
107 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { ArrowLeftRight, LoaderCircle } from "lucide-react";
|
|
import type { SyncOpResult } from "@/lib/sync/envelope";
|
|
import { isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
|
import { currentPosition } from "@/lib/field/image";
|
|
import { otherSessionOf, type OtherSession } from "@/lib/field/time-rules";
|
|
import { btnPrimary, btnSecondary } from "./ui";
|
|
|
|
type StartKind = "travel" | "work" | "resume";
|
|
|
|
async function startPayload(workOrderId: string, mode: "travel" | "work", switchFromOther: boolean) {
|
|
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,
|
|
...(switchFromOther ? { switchFromOther: true } : {}),
|
|
...(pos ?? {}),
|
|
};
|
|
}
|
|
|
|
/** Sends session.start / session.resume (optionally with the auto switch flag). */
|
|
export async function submitStart(workOrderId: string, kind: StartKind, switchFromOther = false): Promise<SyncOpResult> {
|
|
if (kind === "resume") {
|
|
return submitOp({ opType: "session.resume", payload: { workOrderId, at: new Date().toISOString(), ...(switchFromOther ? { switchFromOther: true } : {}) } });
|
|
}
|
|
return submitOp({ opType: "session.start", payload: await startPayload(workOrderId, kind, switchFromOther) });
|
|
}
|
|
|
|
/**
|
|
* L12 auto switch: start/resume work; when another order is still on the clock the server answers
|
|
* `other_session_running` and the bottom sheet asks „A-00041 läuft noch. Pausieren und A-00042 starten?".
|
|
*/
|
|
export function useSessionStart(workOrderId: string, number: string, onDone?: (result: SyncOpResult) => void) {
|
|
const router = useRouter();
|
|
const [busy, setBusy] = useState<StartKind | null>(null);
|
|
const [pending, setPending] = useState<{ kind: StartKind; other: OtherSession } | null>(null);
|
|
const [result, setResult] = useState<SyncOpResult | null>(null);
|
|
|
|
async function start(kind: StartKind, switchFromOther = false) {
|
|
setBusy(kind);
|
|
const r = await submitStart(workOrderId, kind, switchFromOther);
|
|
setBusy(null);
|
|
const other = otherSessionOf(r);
|
|
if (other && !switchFromOther) {
|
|
setPending({ kind, other });
|
|
return r;
|
|
}
|
|
setPending(null);
|
|
setResult(r);
|
|
onDone?.(r);
|
|
if (isSuccess(r)) router.refresh();
|
|
return r;
|
|
}
|
|
|
|
const sheet = pending ? (
|
|
<SwitchSessionSheet
|
|
from={pending.other.number}
|
|
to={number}
|
|
busy={busy !== null}
|
|
onConfirm={() => start(pending.kind, true)}
|
|
onCancel={() => setPending(null)}
|
|
/>
|
|
) : null;
|
|
|
|
return { start, busy, result, sheet };
|
|
}
|
|
|
|
export function SwitchSessionSheet({ from, to, busy, onConfirm, onCancel }: { from: string; to: string; busy: boolean; onConfirm: () => void; onCancel: () => void }) {
|
|
const t = useTranslations("field.switch");
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40" onClick={onCancel}>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="switch-session-title"
|
|
className="w-full max-w-xl space-y-4 rounded-t-2xl bg-card p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-card"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<ArrowLeftRight className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
|
|
<div>
|
|
<h2 id="switch-session-title" className="text-[19px]">
|
|
{t("title")}
|
|
</h2>
|
|
<p className="mt-1 text-[16px]">{from ? t("text", { from, to }) : t("textUnknown", { to })}</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" className={btnPrimary} disabled={busy} onClick={onConfirm} autoFocus>
|
|
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <ArrowLeftRight className="size-5" aria-hidden />}
|
|
{t("confirm")}
|
|
</button>
|
|
<button type="button" className={btnSecondary} disabled={busy} onClick={onCancel}>
|
|
{t("cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|