L12 Zeiterfassung: Mobile Uhr-Leiste, Meine Zeiten, Freigaben und Backoffice-Liste
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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user