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,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, TriangleAlert, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { approveTime, rejectTime } from "@/server/actions/field/time";
|
||||
import { timeErrorKey } from "@/lib/field/time-rules";
|
||||
import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui";
|
||||
|
||||
/** L12 team lead mobile: approve / reject (reason mandatory) with large buttons. */
|
||||
export function ApprovalActions({ timeEntryId }: { timeEntryId: string }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(kind: "approve" | "reject") {
|
||||
setError(null);
|
||||
if (kind === "reject" && reason.trim().length < 3) {
|
||||
setError(t("myTime.form.reasonHint"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const result = await (kind === "approve" ? approveTime({ timeEntryId }) : rejectTime({ timeEntryId, reason: reason.trim() })).catch(() => ({ ok: false as const, error: "failed" as const, message: undefined }));
|
||||
setBusy(false);
|
||||
if (!result.ok) {
|
||||
const key = timeErrorKey({ message: result.message });
|
||||
setError(key ? t(`myTime.errors.${key}`) : t(`errors.${result.error === "failed" ? "internal" : result.error}`));
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{!rejecting ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" className={btnPrimary} disabled={busy} onClick={() => run("approve")}>
|
||||
<Check className="size-5" aria-hidden />
|
||||
{t("approvals.approve")}
|
||||
</button>
|
||||
<button type="button" className={cn(btnSecondary, "min-h-14")} disabled={busy} onClick={() => setRejecting(true)}>
|
||||
<X className="size-5" aria-hidden />
|
||||
{t("approvals.reject")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 rounded-xl bg-muted p-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("approvals.rejectReason")}</span>
|
||||
<textarea rows={2} required minLength={3} maxLength={500} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<button type="button" className={btnPrimary} disabled={busy} onClick={() => run("reject")}>
|
||||
{busy ? t("action.saving") : t("approvals.rejectConfirm")}
|
||||
</button>
|
||||
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setRejecting(false)}>
|
||||
{t("approvals.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,11 @@ const ITEMS = [
|
||||
{ href: "/m/profile", key: "profile", icon: User, exact: false },
|
||||
] as const;
|
||||
|
||||
/** Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. */
|
||||
export function BottomNav() {
|
||||
/**
|
||||
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
|
||||
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
|
||||
*/
|
||||
export function BottomNav({ approvals = 0 }: { approvals?: number }) {
|
||||
const t = useTranslations("field.nav");
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
@@ -23,6 +26,7 @@ export function BottomNav() {
|
||||
<ul className="mx-auto grid max-w-xl grid-cols-5">
|
||||
{ITEMS.map((item) => {
|
||||
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
@@ -33,10 +37,16 @@ export function BottomNav() {
|
||||
active ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn("grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}>
|
||||
<span className={cn("relative grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}>
|
||||
<item.icon className="size-5.5" aria-hidden />
|
||||
{badge > 0 && (
|
||||
<span className="absolute -top-1 -right-0.5 grid min-w-5 place-items-center rounded-full bg-cta px-1 text-[11px] leading-5 font-bold text-cta-foreground" aria-hidden>
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{t(item.key)}
|
||||
{badge > 0 && <span className="sr-only">{t("approvalsBadge", { count: badge })}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LoaderCircle, Pause, Play, TriangleAlert, Wrench } from "lucide-react";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { btnSecondary, noticeError } from "./ui";
|
||||
import { useSessionStart } from "./switch-session-sheet";
|
||||
|
||||
const STARTABLE: WorkOrderStatus[] = ["accepted", "en_route", "in_progress", "paused", "waiting_material", "daily_report_created"];
|
||||
|
||||
/** L12: direct start / pause / resume on an order card (Heute, Aufträge) without opening the order. */
|
||||
export function CardTimeButton({ workOrderId, number, status, mySession }: { workOrderId: string; number: string; status: WorkOrderStatus; mySession: "en_route" | "running" | "paused" | null }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [pausing, setPausing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { start, busy, sheet } = useSessionStart(workOrderId, number, (r) => setError(isSuccess(r) ? null : t(`errors.${errorKey(r)}`)));
|
||||
|
||||
const action: "start" | "pause" | "resume" | null =
|
||||
mySession === "running" ? "pause" : mySession === "paused" ? "resume" : mySession === "en_route" || STARTABLE.includes(status) ? "start" : null;
|
||||
if (!action) return null;
|
||||
|
||||
async function run() {
|
||||
setError(null);
|
||||
if (action === "pause") {
|
||||
setPausing(true);
|
||||
const r = await submitOp({ opType: "session.pause", payload: { workOrderId, at: new Date().toISOString() } });
|
||||
setPausing(false);
|
||||
if (!isSuccess(r)) setError(t(`errors.${errorKey(r)}`));
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
await start(action === "resume" ? "resume" : "work");
|
||||
}
|
||||
|
||||
const loading = pausing || busy !== null;
|
||||
const Icon = loading ? LoaderCircle : action === "pause" ? Pause : action === "resume" ? Play : Wrench;
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={btnSecondary} onClick={run} disabled={loading}>
|
||||
<Icon className={loading ? "size-5 animate-spin" : "size-5"} aria-hidden />
|
||||
{action === "pause" ? t("card.pause") : action === "resume" ? t("card.resume") : t("card.startWork")}
|
||||
</button>
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{sheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PenLine, TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { errorKey, isQueued, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { MANUAL_TIME_TYPES, REASON_SUGGESTIONS, timeErrorKey } from "@/lib/field/time-rules";
|
||||
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||
|
||||
/** ISO → value of <input type="datetime-local"> in the device time zone. */
|
||||
export function toLocalInput(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* L12 „Korrektur vorschlagen" for an own entry (instead of direct editing): the old values stay
|
||||
* valid until a team lead / the office approves. Sent as sync op (works offline).
|
||||
*/
|
||||
export function CorrectionProposalForm({ workOrderId, entry }: { workOrderId: string; entry: { id: string; type: string; startedAt: string; endedAt: string | null } }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [type, setType] = useState(entry.type);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(btnSecondary, "min-h-12")}
|
||||
onClick={() => {
|
||||
setStart(toLocalInput(entry.startedAt));
|
||||
setEnd(entry.endedAt ? toLocalInput(entry.endedAt) : "");
|
||||
setType(entry.type);
|
||||
setDone(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<PenLine className="size-4.5" aria-hidden />
|
||||
{t("time.propose")}
|
||||
</button>
|
||||
{done && (
|
||||
<p className={noticeOk} role="status">
|
||||
{done}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (reason.trim().length < 3) {
|
||||
setError(t("myTime.form.reasonHint"));
|
||||
return;
|
||||
}
|
||||
if (!start || !end) {
|
||||
setError(t("errors.invalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const result = await submitOp({
|
||||
opType: "time.propose_correction",
|
||||
payload: { workOrderId, timeEntryId: entry.id, type: type as (typeof MANUAL_TIME_TYPES)[number], startedAt: new Date(start).toISOString(), endedAt: new Date(end).toISOString(), reason: reason.trim() },
|
||||
});
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
const key = timeErrorKey(result);
|
||||
setError(key ? t(`myTime.errors.${key}`) : t(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
setDone(isQueued(result) ? t("myTime.form.queued") : t("myTime.correction.saved"));
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const types: readonly string[] = (MANUAL_TIME_TYPES as readonly string[]).includes(entry.type) ? MANUAL_TIME_TYPES : [...MANUAL_TIME_TYPES, entry.type];
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3 rounded-xl bg-muted p-3">
|
||||
<p className="text-[15px] font-semibold">{t("myTime.correction.title")}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{t("myTime.correction.hint")}</p>
|
||||
<fieldset>
|
||||
<legend className="mb-1.5 text-[14px] font-semibold">{t("myTime.form.type")}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{types.map((ty) => (
|
||||
<button key={ty} type="button" aria-pressed={type === ty} className={chip(type === ty)} onClick={() => setType(ty)}>
|
||||
{t(`time.type.${ty}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.start")}</span>
|
||||
<input type="datetime-local" required className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.end")}</span>
|
||||
<input type="datetime-local" required className={inputClass} value={end} min={start} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
<ReasonField value={reason} onChange={setReason} />
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className={btnPrimary} disabled={busy}>
|
||||
{busy ? t("action.saving") : t("myTime.correction.save")}
|
||||
</button>
|
||||
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setOpen(false)}>
|
||||
{t("myTime.form.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mandatory reason with suggestion chips („Start vergessen", „Kein Netz", „Nachträglich erfasst"). */
|
||||
export function ReasonField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const t = useTranslations("field.myTime");
|
||||
return (
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("form.reason")}</span>
|
||||
<textarea rows={2} required minLength={3} maxLength={500} className={cn(inputClass, "py-3")} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
</label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{REASON_SUGGESTIONS.map((key) => {
|
||||
const text = t(`reasons.${key}`);
|
||||
return (
|
||||
<button key={key} type="button" aria-pressed={value === text} className={chip(value === text)} onClick={() => onChange(text)}>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="mt-1 block text-[13px] text-muted-foreground">{t("form.reasonHint")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { CloudOff } from "lucide-react";
|
||||
import { fmtDateTime } from "@/lib/field/format";
|
||||
import { listOutbox, subscribeOffline } from "@/lib/offline/outbox";
|
||||
import { isPending } from "@/lib/offline/outbox-core";
|
||||
import type { OutboxEntry } from "@/lib/offline/types";
|
||||
import { card } from "./ui";
|
||||
|
||||
/**
|
||||
* L12 optimistic view: manual time entries and „Für heute beenden" stored on this device but not
|
||||
* yet transmitted (offline outbox) — shown with a pending badge until the server confirms them.
|
||||
*/
|
||||
export function LocalPendingTimes() {
|
||||
const t = useTranslations("field");
|
||||
const locale = useLocale();
|
||||
const [ops, setOps] = useState<OutboxEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () =>
|
||||
void listOutbox()
|
||||
.then((l) => {
|
||||
if (!cancelled) setOps(l.ops.filter((o) => isPending(o) && (o.opType === "time.add_manual" || o.opType === "time.propose_correction" || o.opType === "session.stop_day")));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
load();
|
||||
const unsubscribe = subscribeOffline(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!ops.length) return null;
|
||||
return (
|
||||
<section className={card} aria-label={t("myTime.localTitle")}>
|
||||
<h2 className="flex items-center gap-2 text-[17px]">
|
||||
<CloudOff className="size-5 text-muted-foreground" aria-hidden />
|
||||
{t("myTime.localTitle")}
|
||||
</h2>
|
||||
<ul className="mt-2 divide-y">
|
||||
{ops.map((o) => {
|
||||
const p = o.payload as { type?: string; startedAt?: string; endedAt?: string; durationMinutes?: number; at?: string };
|
||||
const label =
|
||||
o.opType === "session.stop_day" ? t("action.stop_day") : o.opType === "time.propose_correction" ? t("time.propose") : `${t("myTime.add")} · ${t(`time.type.${p.type ?? "work"}`)}`;
|
||||
const when = p.startedAt ?? p.at ?? o.clientCreatedAt;
|
||||
return (
|
||||
<li key={o.clientOpId} className="flex flex-wrap items-baseline justify-between gap-2 py-2.5 text-[15px]">
|
||||
<span className="font-semibold">{label}</span>
|
||||
<span className="text-[14px]">
|
||||
{fmtDateTime(when, locale)}
|
||||
{p.durationMinutes ? ` · ${p.durationMinutes} min` : ""}
|
||||
</span>
|
||||
<span className="inline-flex min-h-7 items-center rounded-lg bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] px-2 text-[13px] font-semibold">{t("myTime.localPending")}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Info, TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fmtDate } from "@/lib/field/format";
|
||||
import { errorKey, isQueued, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||
import { DURATION_QUICK_PICKS, MANUAL_TIME_TYPES, MAX_ENTRY_MINUTES, timeErrorKey } from "@/lib/field/time-rules";
|
||||
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk, noticeWarn } from "./ui";
|
||||
import { ReasonField } from "./correction-proposal-form";
|
||||
|
||||
type Option = { id: string; number: string; title: string };
|
||||
|
||||
/**
|
||||
* L12 „Zeit nachtragen": order, type (chips), date, from–to OR duration (quick picks 15/30/60),
|
||||
* mandatory reason with suggestions. Own entries are pending until approved; with
|
||||
* `field:correct_time` time can be recorded for team members (approved directly).
|
||||
* Sent through the offline outbox (op `time.add_manual`).
|
||||
*/
|
||||
export function ManualTimeForm({ orders, users, days, defaultDate, defaultWorkOrderId }: { orders: Option[]; users: Array<{ id: string; name: string }>; days: string[]; defaultDate: string; defaultWorkOrderId: string | null }) {
|
||||
const t = useTranslations("field");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [workOrderId, setWorkOrderId] = useState(defaultWorkOrderId ?? "");
|
||||
const [forUserId, setForUserId] = useState("");
|
||||
const [type, setType] = useState<(typeof MANUAL_TIME_TYPES)[number]>("work");
|
||||
const [date, setDate] = useState(defaultDate);
|
||||
const [mode, setMode] = useState<"range" | "duration">("range");
|
||||
const [from, setFrom] = useState("08:00");
|
||||
const [to, setTo] = useState("09:00");
|
||||
const [duration, setDuration] = useState(30);
|
||||
const [reason, setReason] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (orders.length === 0) return <p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("myTime.form.noOrders")}</p>;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setDone(null);
|
||||
if (!workOrderId || reason.trim().length < 3) {
|
||||
setError(!workOrderId ? t("myTime.form.orderPlaceholder") : t("myTime.form.reasonHint"));
|
||||
return;
|
||||
}
|
||||
// wall time of the device (same zone as the displayed times)
|
||||
const start = new Date(`${date}T${from}`);
|
||||
const end = mode === "range" ? new Date(`${date}T${to}`) : null;
|
||||
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
|
||||
setError(t("errors.invalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const result = await submitOp({
|
||||
opType: "time.add_manual",
|
||||
payload: {
|
||||
workOrderId,
|
||||
clientId: newClientId(),
|
||||
type,
|
||||
startedAt: start.toISOString(),
|
||||
...(end ? { endedAt: end.toISOString() } : { durationMinutes: duration }),
|
||||
reason: reason.trim(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
...(forUserId ? { forUserId } : {}),
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
const key = timeErrorKey(result);
|
||||
setError(key ? t(`myTime.errors.${key}`) : t(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
if (isQueued(result)) {
|
||||
setDone(t("myTime.form.queued"));
|
||||
setReason("");
|
||||
return;
|
||||
}
|
||||
router.push(`/m/time?date=${date}`);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const label = "mb-1.5 block text-[14px] font-semibold";
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.order")}</span>
|
||||
<select required className={inputClass} value={workOrderId} onChange={(e) => setWorkOrderId(e.target.value)}>
|
||||
<option value="">{t("myTime.form.orderPlaceholder")}</option>
|
||||
{orders.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.number} · {o.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{users.length > 0 && (
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.forUser")}</span>
|
||||
<select className={inputClass} value={forUserId} onChange={(e) => setForUserId(e.target.value)}>
|
||||
<option value="">{t("myTime.form.me")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<fieldset>
|
||||
<legend className={label}>{t("myTime.form.type")}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MANUAL_TIME_TYPES.map((ty) => (
|
||||
<button key={ty} type="button" aria-pressed={type === ty} className={chip(type === ty)} onClick={() => setType(ty)}>
|
||||
{t(`time.type.${ty}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.date")}</span>
|
||||
<select className={inputClass} value={date} onChange={(e) => setDate(e.target.value)}>
|
||||
{days.map((d, i) => (
|
||||
<option key={d} value={d}>
|
||||
{i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${d}T12:00:00Z`, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend className={label}>{t("myTime.form.mode")}</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" aria-pressed={mode === "range"} className={chip(mode === "range")} onClick={() => setMode("range")}>
|
||||
{t("myTime.form.modeRange")}
|
||||
</button>
|
||||
<button type="button" aria-pressed={mode === "duration"} className={chip(mode === "duration")} onClick={() => setMode("duration")}>
|
||||
{t("myTime.form.modeDuration")}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.from")}</span>
|
||||
<input type="time" required className={inputClass} value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</label>
|
||||
{mode === "range" ? (
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.to")}</span>
|
||||
<input type="time" required className={inputClass} value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</label>
|
||||
) : (
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.duration")}</span>
|
||||
<input type="number" inputMode="numeric" min={1} max={MAX_ENTRY_MINUTES} required className={inputClass} value={duration} onChange={(e) => setDuration(Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{mode === "duration" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DURATION_QUICK_PICKS.map((m) => (
|
||||
<button key={m} type="button" aria-pressed={duration === m} className={cn(chip(duration === m), "flex-1")} onClick={() => setDuration(m)}>
|
||||
{t("myTime.form.quick", { minutes: m })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReasonField value={reason} onChange={setReason} />
|
||||
|
||||
<label className="block">
|
||||
<span className={label}>{t("myTime.form.note")}</span>
|
||||
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<p className={forUserId ? noticeOk : noticeWarn}>
|
||||
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{forUserId ? t("myTime.form.directHint") : t("myTime.form.approvalHint")}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{done && (
|
||||
<p className={noticeOk} role="status">
|
||||
{done}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className={btnPrimary} disabled={busy}>
|
||||
{busy ? t("action.saving") : t("myTime.form.save")}
|
||||
</button>
|
||||
<button type="button" className={btnSecondary} disabled={busy} onClick={() => router.push(`/m/time?date=${date}`)}>
|
||||
{t("myTime.form.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { ChevronRight, Clock, MapPin, Navigation, Siren } from "lucide-react";
|
||||
import { ChevronRight, Clock, MapPin, Navigation, Siren, Timer } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fmtWindow } from "@/lib/field/format";
|
||||
import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import { btnPrimary, toneClasses } from "./ui";
|
||||
import { btnPrimary, btnSecondary, toneClasses } from "./ui";
|
||||
import { CardTimeButton } from "./card-time-button";
|
||||
|
||||
/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
|
||||
export function OrderCard({ order }: { order: OrderCardData }) {
|
||||
export function OrderCard({ order, canExecute = false }: { order: OrderCardData; canExecute?: boolean }) {
|
||||
const t = useTranslations("field.card");
|
||||
const locale = useLocale();
|
||||
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
|
||||
const clock = order.mySession;
|
||||
return (
|
||||
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup).edge)}>
|
||||
<article
|
||||
className={cn(
|
||||
"rounded-xl border border-l-4 bg-card p-4 shadow-card",
|
||||
// L12: the card whose clock runs is highlighted by edge AND text (never by colour alone)
|
||||
clock === "running" || clock === "en_route" ? "border-l-8 border-l-[var(--ok)] ring-2 ring-[color-mix(in_oklch,var(--ok)_35%,transparent)]" : clock === "paused" ? "border-l-8 border-l-[var(--warn)]" : toneClasses(order.statusGroup).edge,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
{clock && (
|
||||
<p className={cn("mt-2 inline-flex items-center gap-1.5 text-[14px] font-bold", clock === "paused" ? "text-[var(--warn)]" : "text-[var(--ok)]")}>
|
||||
<Timer className="size-4.5" aria-hidden />
|
||||
{clock === "paused" ? t("paused") : clock === "en_route" ? t("enRoute") : t("running")}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
|
||||
<p className="mt-0.5 text-[15px] font-semibold text-foreground">{order.customerName}</p>
|
||||
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
|
||||
@@ -52,10 +66,13 @@ export function OrderCard({ order }: { order: OrderCardData }) {
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<Link href={`/m/orders/${order.id}`} className={cn(btnPrimary, "mt-4")}>
|
||||
{t("open")}
|
||||
<ChevronRight className="size-5" aria-hidden />
|
||||
</Link>
|
||||
<div className="mt-4 space-y-2">
|
||||
{canExecute && <CardTimeButton workOrderId={order.id} number={order.number} status={order.status} mySession={order.mySession} />}
|
||||
<Link href={`/m/orders/${order.id}`} className={canExecute && order.mySession ? btnSecondary : btnPrimary}>
|
||||
{t("open")}
|
||||
<ChevronRight className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Clock } from "lucide-react";
|
||||
import { fmtDuration } from "@/lib/field/format";
|
||||
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||
import { pendingTimeOfOrder } from "@/server/services/field/time-entries";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
import { noticeWarn } from "./ui";
|
||||
|
||||
async function loadPending(workOrderId: string) {
|
||||
try {
|
||||
const ctx = await fieldPageContext();
|
||||
await requireVisibleWorkOrder(ctx, workOrderId, { id: true });
|
||||
return await pendingTimeOfOrder(ctx, workOrderId);
|
||||
} catch {
|
||||
// not visible / no field context: the report screen handles access itself
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L12: warns (does not block) before submitting a report while manual time entries or correction
|
||||
* proposals of the order still wait for approval — they are not part of the report totals.
|
||||
*/
|
||||
export async function PendingTimeNotice({ workOrderId }: { workOrderId: string }) {
|
||||
const pending = await loadPending(workOrderId);
|
||||
if (!pending || (pending.pendingEntries === 0 && pending.openProposals === 0)) return null;
|
||||
const t = await getTranslations("field.pendingTime");
|
||||
return (
|
||||
<div className="px-4 pt-4">
|
||||
<p className={noticeWarn} role="note">
|
||||
<Clock className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{pending.pendingEntries > 0 && t("report", { duration: fmtDuration(pending.pendingMinutes * 60) })}
|
||||
{pending.pendingEntries > 0 && pending.openProposals > 0 && " "}
|
||||
{pending.openProposals > 0 && t("proposals", { count: pending.openProposals })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,90 +3,93 @@
|
||||
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 { CircleCheck, LoaderCircle, Package, Pause, Play, Square, TriangleAlert, Truck, Undo2, 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 { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
|
||||
import { useSessionStart } from "./switch-session-sheet";
|
||||
|
||||
type SessionState = "en_route" | "running" | "paused" | null;
|
||||
type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "complete";
|
||||
type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "stop_day" | "complete";
|
||||
type SegmentKey = "work" | "return_travel" | "material_procurement";
|
||||
|
||||
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 };
|
||||
/**
|
||||
* One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter.
|
||||
* L12: with an own session the primary action is Pause (or Weiter), secondary „Für heute beenden",
|
||||
* „Abschließen" as a separate text link (`link`) — max. 2 large buttons + 1 link.
|
||||
*/
|
||||
export function resolveActions(
|
||||
status: WorkOrderStatus,
|
||||
mySession: SessionState,
|
||||
): { primary: ActionKey | null; secondary: ActionKey | null; link: "complete" | null } {
|
||||
if (status === "assigned") return { primary: "accept", secondary: null, link: null };
|
||||
if (status === "accepted") return { primary: "travel", secondary: "start", link: null };
|
||||
if (status === "en_route") return { primary: "start", secondary: mySession === "en_route" ? "stop_day" : null, link: 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 };
|
||||
if (mySession === "running") return { primary: "pause", secondary: "stop_day", link: "complete" };
|
||||
if (mySession === "paused") return { primary: "resume", secondary: "stop_day", link: "complete" };
|
||||
if (mySession === "en_route") return { primary: "start", secondary: "stop_day", link: null };
|
||||
return { primary: "start", secondary: null, link: status === "in_progress" ? "complete" : null };
|
||||
}
|
||||
return { primary: null, secondary: null };
|
||||
return { primary: null, secondary: null, link: 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 ?? {}),
|
||||
};
|
||||
}
|
||||
const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, stop_day: Square, complete: CircleCheck } as const;
|
||||
const SEGMENT_ICONS = { work: Undo2, return_travel: Truck, material_procurement: Package } as const;
|
||||
|
||||
export function PrimaryAction({
|
||||
workOrderId,
|
||||
number,
|
||||
status,
|
||||
version,
|
||||
mySession,
|
||||
segmentType,
|
||||
blockers,
|
||||
}: {
|
||||
workOrderId: string;
|
||||
number: string;
|
||||
status: WorkOrderStatus;
|
||||
version: number;
|
||||
mySession: SessionState;
|
||||
/** type of the open segment of the own session (L12 segment switch) */
|
||||
segmentType?: string | null;
|
||||
/** 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 [busy, setBusy] = useState<ActionKey | SegmentKey | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const { primary, secondary } = resolveActions(status, mySession);
|
||||
const [confirm, setConfirm] = useState<"complete" | "stop_day" | null>(null);
|
||||
const { primary, secondary, link } = resolveActions(status, mySession);
|
||||
const starter = useSessionStart(workOrderId, number, (r) => setError(isSuccess(r) ? null : t(`errors.${errorKey(r)}`)));
|
||||
|
||||
async function run(action: ActionKey) {
|
||||
if (action === "complete" && !confirm) {
|
||||
setConfirm(true);
|
||||
if ((action === "complete" || action === "stop_day") && confirm !== action) {
|
||||
setConfirm(action);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (action === "travel" || action === "start" || action === "resume") {
|
||||
setConfirm(null);
|
||||
await starter.start(action === "travel" ? "travel" : action === "resume" ? "resume" : "work");
|
||||
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 } });
|
||||
case "stop_day":
|
||||
result = await submitOp({ opType: "session.stop_day", payload: { workOrderId, at } });
|
||||
break;
|
||||
case "complete": {
|
||||
let base = version;
|
||||
@@ -103,7 +106,16 @@ export function PrimaryAction({
|
||||
}
|
||||
}
|
||||
setBusy(null);
|
||||
setConfirm(false);
|
||||
setConfirm(null);
|
||||
if (result && !isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function segment(type: SegmentKey) {
|
||||
setBusy(type);
|
||||
setError(null);
|
||||
const result = await submitOp({ opType: "session.segment", payload: { workOrderId, type, at: new Date().toISOString() } });
|
||||
setBusy(null);
|
||||
if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
|
||||
router.refresh();
|
||||
}
|
||||
@@ -113,33 +125,83 @@ export function PrimaryAction({
|
||||
}
|
||||
|
||||
const completeBlocked = blockers.length > 0;
|
||||
const anyBusy = busy !== null || starter.busy !== null;
|
||||
const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
|
||||
const Icon = busy === action ? LoaderCircle : ICONS[action];
|
||||
const disabled = busy !== null || (action === "complete" && completeBlocked);
|
||||
const loading = busy === action || (starter.busy !== null && (action === "travel" || action === "start" || action === "resume"));
|
||||
const Icon = loading ? LoaderCircle : ICONS[action];
|
||||
const disabled = anyBusy || (action === "complete" && completeBlocked);
|
||||
const label = loading ? t("action.saving") : confirm === action ? t(action === "complete" ? "action.confirmComplete" : "action.confirmStopDay") : t(`action.${action}`);
|
||||
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}`)}
|
||||
<Icon className={loading ? "size-5 animate-spin" : "size-5"} aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// segment switches (smaller actions) while the own session is running or paused
|
||||
const segments: SegmentKey[] =
|
||||
mySession === "running" || mySession === "paused"
|
||||
? (["return_travel", "material_procurement", "work"] as SegmentKey[]).filter((s) => (s === "work" ? mySession === "running" && !!segmentType && segmentType !== "work" : s !== segmentType))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{renderButton(primary, "primary")}
|
||||
{secondary && renderButton(secondary, "secondary")}
|
||||
{confirm && (
|
||||
{segments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{mySession === "running" && segmentType && segmentType !== "work" && (
|
||||
<p className="w-full text-[13px] font-semibold text-muted-foreground">{t("action.currentSegment", { type: t(`time.type.${segmentType}`) })}</p>
|
||||
)}
|
||||
{segments.map((s) => {
|
||||
const Icon = busy === s ? LoaderCircle : SEGMENT_ICONS[s];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={anyBusy}
|
||||
onClick={() => segment(s)}
|
||||
className="inline-flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3 text-[14px] font-semibold text-primary disabled:opacity-50"
|
||||
>
|
||||
<Icon className={cn("size-4.5", busy === s && "animate-spin")} aria-hidden />
|
||||
{t(`action.segment.${s}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{link === "complete" && confirm !== "complete" && (
|
||||
<button type="button" disabled={anyBusy || completeBlocked} onClick={() => run("complete")} className="inline-flex min-h-12 items-center px-1 text-[15px] font-semibold text-primary underline underline-offset-4 disabled:opacity-50">
|
||||
{t("action.completeLink")}
|
||||
</button>
|
||||
)}
|
||||
{confirm === "complete" && (
|
||||
<>
|
||||
{renderButton("complete", "secondary")}
|
||||
<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(null)}>
|
||||
{t("photos.discard")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{confirm === "stop_day" && (
|
||||
<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("action.stopDayHint")}{" "}
|
||||
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(null)}>
|
||||
{t("photos.discard")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{completeBlocked && (primary === "complete" || secondary === "complete") && (
|
||||
{completeBlocked && (primary === "complete" || secondary === "complete" || link === "complete") && (
|
||||
<div className={noticeWarn} role="note">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||
<div>
|
||||
@@ -158,6 +220,7 @@ export function PrimaryAction({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{starter.sheet}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { History, LoaderCircle, Pause, Play, Square } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { subscribeOffline } from "@/lib/offline/outbox";
|
||||
import { readOrders } from "@/lib/offline/read";
|
||||
import type { MyActiveSession } from "@/server/services/field/sessions";
|
||||
import { useSessionStart } from "./switch-session-sheet";
|
||||
|
||||
export type ClockSession = Pick<MyActiveSession, "status" | "workOrderId" | "number" | "title" | "segmentStartedAt" | "closedSeconds"> & { segmentType: string | null };
|
||||
|
||||
/** "1:23 h" */
|
||||
function fmtClock(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds / 60));
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} h`;
|
||||
}
|
||||
|
||||
function elapsed(s: ClockSession, now: number): number {
|
||||
const running = s.status !== "paused" && s.segmentStartedAt && s.segmentType !== "break" ? Math.max(0, (now - new Date(s.segmentStartedAt).getTime()) / 1000) : 0;
|
||||
return s.closedSeconds + running;
|
||||
}
|
||||
|
||||
/** Offline fallback: the own session from the local bundle + queued ops (no durations available locally). */
|
||||
async function localSession(): Promise<ClockSession | null> {
|
||||
const read = await readOrders().catch(() => null);
|
||||
if (!read?.ready) return null;
|
||||
const order = read.orders.find((o) => o.local.session === "running" || o.local.session === "en_route") ?? read.orders.find((o) => o.local.session === "paused");
|
||||
if (!order) return null;
|
||||
return {
|
||||
status: order.local.session as ClockSession["status"],
|
||||
workOrderId: order.id,
|
||||
number: order.number,
|
||||
title: order.title,
|
||||
segmentStartedAt: order.mySession?.startedAt ?? null,
|
||||
segmentType: order.local.session === "paused" ? "break" : "work",
|
||||
closedSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* L12 running clock: fixed above the bottom navigation on every /m page while an own session runs
|
||||
* or is paused — „A-00042 · Wärmepumpe · 1:23 h", Pause/Weiter, „Für heute beenden", tap → order.
|
||||
* Server data on every render (router.refresh after actions); offline from the local state.
|
||||
*/
|
||||
export function RunningClockBar({ initial }: { initial: ClockSession | null }) {
|
||||
const t = useTranslations("field.clock");
|
||||
const router = useRouter();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [offlineSession, setOfflineSession] = useState<ClockSession | null | undefined>(undefined);
|
||||
const [busy, setBusy] = useState<"pause" | "stop" | null>(null);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
if (typeof navigator === "undefined" || navigator.onLine) {
|
||||
setOfflineSession(undefined);
|
||||
return;
|
||||
}
|
||||
void localSession().then((s) => {
|
||||
if (!cancelled) setOfflineSession(s);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const unsubscribe = subscribeOffline(refresh);
|
||||
window.addEventListener("online", refresh);
|
||||
window.addEventListener("offline", refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.removeEventListener("online", refresh);
|
||||
window.removeEventListener("offline", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const session = offlineSession === undefined ? initial : offlineSession;
|
||||
const resume = useSessionStart(session?.workOrderId ?? "", session?.number ?? "");
|
||||
if (!session) return null;
|
||||
const offline = offlineSession !== undefined;
|
||||
|
||||
async function pause() {
|
||||
if (!session) return;
|
||||
setBusy("pause");
|
||||
await submitOp({ opType: "session.pause", payload: { workOrderId: session.workOrderId, at: new Date().toISOString() } });
|
||||
setBusy(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function stopDay() {
|
||||
if (!session) return;
|
||||
if (!confirmStop) {
|
||||
setConfirmStop(true);
|
||||
return;
|
||||
}
|
||||
setBusy("stop");
|
||||
const r = await submitOp({ opType: "session.stop_day", payload: { workOrderId: session.workOrderId, at: new Date().toISOString() } });
|
||||
setBusy(null);
|
||||
setConfirmStop(false);
|
||||
if (isSuccess(r)) router.refresh();
|
||||
}
|
||||
|
||||
const statusLabel = session.status === "paused" ? t("paused") : session.status === "en_route" ? t("enRoute") : t("running");
|
||||
const btn = "inline-flex min-h-12 min-w-12 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-[14px] font-semibold disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("label")}
|
||||
className={cn(
|
||||
"fixed inset-x-0 z-30 border-t border-l-4 bg-card shadow-card",
|
||||
"bottom-[calc(4rem+1px+env(safe-area-inset-bottom))]",
|
||||
session.status === "paused" ? "border-l-[var(--warn)]" : "border-l-[var(--ok)]",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-xl items-center gap-2 px-3 py-2">
|
||||
<Link href={`/m/orders/${session.workOrderId}`} aria-label={t("openOrder", { number: session.number })} className="flex min-h-12 min-w-0 flex-1 flex-col justify-center">
|
||||
<span className="flex items-center gap-1.5 text-[12px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<span className={cn("inline-block size-2 rounded-full", session.status === "paused" ? "bg-[var(--warn)]" : "animate-pulse bg-[var(--ok)]")} aria-hidden />
|
||||
{statusLabel}
|
||||
{offline && ` · ${t("offline")}`}
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-semibold">
|
||||
{session.number} · {session.title}
|
||||
{!offline && <span className="tabular-nums"> · {fmtClock(elapsed(session, now))}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/m/time" aria-label={t("myTime")} className={cn(btn, "text-primary")}>
|
||||
<History className="size-5" aria-hidden />
|
||||
</Link>
|
||||
{session.status === "paused" ? (
|
||||
<button type="button" className={cn(btn, "bg-cta text-cta-foreground")} disabled={resume.busy !== null} onClick={() => resume.start("resume")}>
|
||||
{resume.busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Play className="size-5" aria-hidden />}
|
||||
{t("resume")}
|
||||
</button>
|
||||
) : session.status === "running" ? (
|
||||
<button type="button" className={cn(btn, "border border-border text-primary")} disabled={busy !== null} onClick={pause}>
|
||||
{busy === "pause" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Pause className="size-5" aria-hidden />}
|
||||
{t("pause")}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(btn, confirmStop ? "bg-destructive text-primary-foreground" : "border border-border text-foreground")}
|
||||
disabled={busy !== null}
|
||||
onClick={stopDay}
|
||||
onBlur={() => setConfirmStop(false)}
|
||||
aria-label={t("stopDay")}
|
||||
>
|
||||
{busy === "stop" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Square className="size-4.5" aria-hidden />}
|
||||
<span className="max-[400px]:sr-only">{confirmStop ? t("stopDayConfirm") : t("stopDay")}</span>
|
||||
</button>
|
||||
</div>
|
||||
{resume.sheet}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { CircleCheck, Clock, PenLine, TriangleAlert } from "lucide-react";
|
||||
import { fmtTime } from "@/lib/field/format";
|
||||
|
||||
type BadgeEntry = {
|
||||
source: "tracked" | "manual";
|
||||
approvalStatus: "approved" | "pending" | "rejected";
|
||||
rejectionReason: string | null;
|
||||
corrected: boolean;
|
||||
pendingChange: { startedAt: string; endedAt: string; type: string } | null;
|
||||
};
|
||||
|
||||
const badge = "inline-flex min-h-7 items-center gap-1 rounded-lg px-2 text-[13px] font-semibold";
|
||||
|
||||
/** L12 status badges of a time entry — always text + icon, never colour alone. */
|
||||
export function TimeEntryBadges({ entry }: { entry: BadgeEntry }) {
|
||||
const t = useTranslations("field.myTime.badge");
|
||||
const tt = useTranslations("field");
|
||||
const locale = useLocale();
|
||||
const items: React.ReactNode[] = [];
|
||||
if (entry.source === "manual") {
|
||||
items.push(
|
||||
<span key="manual" className={`${badge} bg-muted text-foreground`}>
|
||||
<PenLine className="size-3.5" aria-hidden />
|
||||
{t("manual")}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (entry.approvalStatus === "pending") {
|
||||
items.push(
|
||||
<span key="pending" className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
|
||||
<Clock className="size-3.5" aria-hidden />
|
||||
{t("pending")}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (entry.approvalStatus === "rejected") {
|
||||
items.push(
|
||||
<span key="rejected" className={`${badge} bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]`}>
|
||||
<TriangleAlert className="size-3.5" aria-hidden />
|
||||
{t("rejected", { reason: entry.rejectionReason ?? "" })}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (entry.approvalStatus === "approved" && entry.pendingChange) {
|
||||
items.push(
|
||||
<span key="proposal" className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
|
||||
<Clock className="size-3.5" aria-hidden />
|
||||
{t("correctionRequested")} ·{" "}
|
||||
{tt("myTime.proposed", { from: fmtTime(entry.pendingChange.startedAt, locale), to: fmtTime(entry.pendingChange.endedAt, locale), type: tt(`time.type.${entry.pendingChange.type}`) })}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (entry.approvalStatus === "approved" && !entry.pendingChange && entry.rejectionReason) {
|
||||
items.push(
|
||||
<span key="proposalRejected" className={`${badge} bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]`}>
|
||||
<TriangleAlert className="size-3.5" aria-hidden />
|
||||
{t("correctionRejected", { reason: entry.rejectionReason })}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (entry.corrected) {
|
||||
items.push(
|
||||
<span key="corrected" className={`${badge} bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]`}>
|
||||
<CircleCheck className="size-3.5" aria-hidden />
|
||||
{t("corrected")}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (!items.length) return null;
|
||||
return <div className="flex flex-wrap gap-1.5">{items}</div>;
|
||||
}
|
||||
@@ -399,18 +399,20 @@ function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () =>
|
||||
);
|
||||
}
|
||||
|
||||
type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end";
|
||||
type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end" | "stop_day";
|
||||
|
||||
function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
|
||||
const t = useTranslations("offline.view");
|
||||
const tf = useTranslations("field");
|
||||
const { feedback, report } = useFeedback();
|
||||
const [busy, setBusy] = useState<Action | null>(null);
|
||||
const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session);
|
||||
const actions = [resolved.primary, resolved.secondary]
|
||||
.map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a))
|
||||
.filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i);
|
||||
const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete";
|
||||
const labels: Record<Action, string> = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd") };
|
||||
// L12: „Abschließen" is a text link in resolveActions; offline it stays a hint
|
||||
const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete" || resolved.link === "complete";
|
||||
const labels: Record<Action, string> = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd"), stop_day: tf("action.stop_day") };
|
||||
|
||||
async function run(action: Action) {
|
||||
setBusy(action);
|
||||
@@ -423,7 +425,7 @@ function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () =>
|
||||
? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } })
|
||||
: action === "travel" || action === "start"
|
||||
? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") })
|
||||
: await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : "session.end", payload: { workOrderId, at } });
|
||||
: await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : action === "stop_day" ? "session.stop_day" : "session.end", payload: { workOrderId, at } });
|
||||
setBusy(null);
|
||||
report(result);
|
||||
onChanged();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
removePhotoRequirementAction,
|
||||
} from "@/server/actions/work_orders/work-orders";
|
||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||
import { approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
|
||||
import { ActionForm } from "@/components/work-orders/action-form";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
@@ -300,10 +301,35 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const sessions = await getTimesTab(ctx, wo.id);
|
||||
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
||||
// L12: approved vs. pending totals, badges and inline approval
|
||||
const canApprove = ctx.permissions.has("time:approve");
|
||||
const pendingTotal = sessions.reduce((sum, s) => sum + s.pendingMinutes, 0);
|
||||
const openDecisions = sessions.flatMap((s) => s.entries).filter((e) => e.approvalStatus === "pending" || e.pendingChange !== null).length;
|
||||
const badge = "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="font-semibold">
|
||||
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
|
||||
</span>
|
||||
{pendingTotal > 0 && <span className="font-semibold text-[var(--warn)]">{t("times.pendingMinutes", { minutes: pendingTotal })}</span>}
|
||||
{canApprove && openDecisions > 0 && (
|
||||
<Link href="/work-orders/time-approvals" className="font-semibold text-[var(--primary)] hover:underline">
|
||||
{t("times.openApprovals")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<Section key={s.id} title={`${s.user.name} · ${t(`times.sessionStatus.${s.status}`)}`} actions={<span className="text-sm font-semibold">{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}</span>}>
|
||||
<Section
|
||||
key={s.id}
|
||||
title={`${s.user.name} · ${s.manual ? t("times.manualSession") : t(`times.sessionStatus.${s.status}`)}`}
|
||||
actions={
|
||||
<span className="text-sm font-semibold">
|
||||
{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}
|
||||
{s.pendingMinutes > 0 && <span className="ml-2 text-[var(--warn)]">{t("times.pendingMinutes", { minutes: s.pendingMinutes })}</span>}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-sm">
|
||||
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||
@@ -320,7 +346,36 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
|
||||
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
|
||||
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
|
||||
<td className="py-1.5 text-xs text-[var(--warn)]">{e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""}</td>
|
||||
<td className="space-y-1 py-1.5 text-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{e.source === "manual" && <span className={`${badge} bg-muted text-foreground`}>{t("times.badgeManual")}</span>}
|
||||
{e.approvalStatus === "pending" && <span className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>{t("times.badgePending")}</span>}
|
||||
{e.approvalStatus === "rejected" && <span className={`${badge} text-[var(--risk)]`}>{t("times.badgeRejected", { reason: e.rejectionReason ?? "" })}</span>}
|
||||
{e.pendingChange !== null && (
|
||||
<span className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
|
||||
{t("times.badgeCorrection", {
|
||||
from: formatDateTime(new Date(String((e.pendingChange as { startedAt?: string }).startedAt)), locale, tz),
|
||||
to: formatDateTime(new Date(String((e.pendingChange as { endedAt?: string }).endedAt)), locale, tz),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{e.corrected && <p className="text-[var(--warn)]">{t("times.corrected", { reason: e.correctionReason ?? "" })}</p>}
|
||||
{!e.corrected && e.source === "manual" && e.correctionReason && <p className="text-muted-foreground">{t("times.reason", { reason: e.correctionReason })}</p>}
|
||||
{canApprove && e.userId !== ctx.userId && (e.approvalStatus === "pending" || e.pendingChange !== null) && (
|
||||
<div className="flex flex-wrap items-start gap-2 pt-1">
|
||||
<ActionForm action={approveTimeEntryAction} submitLabel={t("times.approve")} variant="primary" successText={t("times.approved")} footerClassName="mt-0">
|
||||
<Hidden name="timeEntryId" value={e.id} />
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
</ActionForm>
|
||||
<ActionForm action={rejectTimeEntryAction} submitLabel={t("times.reject")} variant="outline" successText={t("times.rejected")} className="flex flex-wrap items-start gap-2" footerClassName="mt-0">
|
||||
<Hidden name="timeEntryId" value={e.id} />
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<input name="reason" required minLength={3} maxLength={500} placeholder={t("times.rejectReason")} aria-label={t("times.rejectReason")} className={inputCls} />
|
||||
</ActionForm>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
|
||||
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||
|
||||
/**
|
||||
* L12 bulk approval form with a DOM id, so row checkboxes elsewhere in the table can join it via
|
||||
* the `form` attribute (row approve/reject forms stay separate forms — no nesting).
|
||||
*/
|
||||
export function BulkApproveForm({ id, action, label, successText }: { id: string; action: Action; label: string; successText: string }) {
|
||||
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||
const t = useTranslations("workOrders");
|
||||
return (
|
||||
<form id={id} action={formAction} className="flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={pending} className={buttonCls("primary")}>
|
||||
{label}
|
||||
</button>
|
||||
{state.status === "error" && (
|
||||
<span role="alert" className="flex items-center gap-1.5 text-sm text-[var(--risk)]">
|
||||
<AlertCircle className="size-4" aria-hidden />
|
||||
{t.has(`errors.${state.message}`) ? t(`errors.${state.message}`) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal")}
|
||||
</span>
|
||||
)}
|
||||
{state.status === "ok" && (
|
||||
<span role="status" className="flex items-center gap-1.5 text-sm text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{successText}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Alle auswählen" for the checkboxes of one bulk form. */
|
||||
export function SelectAllBox({ formId, label }: { formId: string; label: string }) {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
className="size-5"
|
||||
onChange={(e) => {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=checkbox][form="${formId}"][name="ids"]`).forEach((box) => {
|
||||
box.checked = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user