Merge lane/einsatz in feature/craftvia-mvp
Konflikte gelöst: processors/index.ts (report-pdf + image-derivatives), (app)/layout.tsx (Logo, Glocke, AccountInactiveNotice). L5-Mobilseiten report/sign nach src/app/(field)/m/(core)/orders/[id]/ verschoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { signOut } from "@/server/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
|
||||
/** Hinweis für deaktivierte Konten — gemeinsam für Backoffice- und Mobile-Shell (src/server/app-access.ts). */
|
||||
export function AccountInactiveNotice() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
||||
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
|
||||
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
|
||||
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
|
||||
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,8 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
tenant_mail_settings: "E-Mail-Versand",
|
||||
sync_operation: "Synchronisation",
|
||||
document: "Dokument",
|
||||
// Einsatz mobil (L4)
|
||||
work_session: "Einsatz-Zeiterfassung", time_entry: "Zeitabschnitt", checklist_item: "Checklistenpunkt", material_usage: "Materialverbrauch", photo: "Foto", voice_note: "Sprachnotiz", activity_note: "Tätigkeitsnotiz", sync_operation: "Sync-Vorgang",
|
||||
};
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ITEMS = [
|
||||
{ href: "/m", key: "today", icon: House, exact: true },
|
||||
{ href: "/m/orders", key: "orders", icon: ClipboardList, exact: false },
|
||||
{ href: "/m/emergency", key: "emergency", icon: Siren, exact: false },
|
||||
{ href: "/m/sync", key: "sync", icon: RefreshCw, exact: false },
|
||||
{ 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() {
|
||||
const t = useTranslations("field.nav");
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
|
||||
<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}/`);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-16 flex-col items-center justify-center gap-1 text-[12px] font-semibold",
|
||||
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")}>
|
||||
<item.icon className="size-5.5" aria-hidden />
|
||||
</span>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Camera, Check, TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { btnSecondary, card, inputClass, noticeError } from "./ui";
|
||||
|
||||
type Item = { id: string; label: string; required: boolean; requiresPhoto: boolean; checked: boolean; comment: string | null };
|
||||
|
||||
/** One checklist item (Spec §12.4): large toggle + optional comment. Optimistic, reverted on failure. */
|
||||
export function ChecklistItemRow({ workOrderId, item }: { workOrderId: string; item: Item }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [checked, setChecked] = useState(item.checked);
|
||||
const [comment, setComment] = useState(item.comment ?? "");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function send(nextChecked: boolean, nextComment?: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const previous = checked;
|
||||
setChecked(nextChecked);
|
||||
const result = await submitOp({
|
||||
opType: "checklist.toggle",
|
||||
payload: { workOrderId, itemId: item.id, checked: nextChecked, ...(nextComment !== undefined ? { comment: nextComment } : {}) },
|
||||
});
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
setChecked(previous);
|
||||
setError(t(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={cn(card, "space-y-3")}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={busy}
|
||||
onClick={() => send(!checked)}
|
||||
className="flex min-h-14 w-full items-center gap-3 text-left"
|
||||
>
|
||||
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border-2", checked ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "border-input bg-card")}>
|
||||
{checked && <Check className="size-6" aria-hidden />}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-[16px] font-semibold leading-snug">{item.label}</span>
|
||||
<span className="mt-1 flex flex-wrap gap-1.5 text-[12.5px]">
|
||||
<span className={cn("font-semibold", checked ? "text-[var(--ok)]" : "text-muted-foreground")}>{checked ? t("checklist.done") : t("checklist.open")}</span>
|
||||
{item.required && <span className="rounded-full bg-muted px-2 font-semibold">{t("checklist.required")}</span>}
|
||||
{item.requiresPhoto && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 font-semibold">
|
||||
<Camera className="size-3.5" aria-hidden />
|
||||
{t("checklist.photo")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea rows={2} maxLength={2000} aria-label={t("checklist.comment")} className={cn(inputClass, "py-3")} value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
<button type="button" className={btnSecondary} disabled={busy} onClick={() => send(checked, comment)}>
|
||||
{t("checklist.saveComment")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setEditing(true)} className="min-h-12 w-full rounded-xl px-1 text-left text-[14px] text-muted-foreground">
|
||||
{comment ? comment : `${t("checklist.comment")} …`}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CircleCheck, TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UNIT_SUGGESTIONS, type MaterialUsageStatus } from "@/lib/sync/ops";
|
||||
import { materialDeviates, validateMaterialUsage } from "@/lib/field/material-rules";
|
||||
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||
import { Stepper } from "./stepper";
|
||||
import { btnPrimary, card, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||
|
||||
type PlanItem = { id: string; name: string; articleNumber: string | null; plannedQuantity: number; unit: string; notes: string | null };
|
||||
type Usage = { usageStatus: MaterialUsageStatus; quantity: number; unit: string; deviationReason: string | null } | null;
|
||||
|
||||
const PLAN_STATUSES: MaterialUsageStatus[] = ["fully_used", "partially_used", "not_used"];
|
||||
const fmtQty = (n: number) => String(n).replace(".", ",");
|
||||
|
||||
function Feedback({ error, saved, savedLabel }: { error: string | null; saved: boolean; savedLabel: string }) {
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p className={noticeOk} role="status">
|
||||
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{savedLabel}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirm a planned material position: fully / partially / not used, quantity, reason on deviation. */
|
||||
export function MaterialPlanItem({ workOrderId, plan, usage }: { workOrderId: string; plan: PlanItem; usage: Usage }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<MaterialUsageStatus>(usage?.usageStatus ?? "fully_used");
|
||||
const [quantity, setQuantity] = useState<number>(usage?.quantity ?? plan.plannedQuantity);
|
||||
const [reason, setReason] = useState(usage?.deviationReason ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const deviates = materialDeviates({ usageStatus: status, quantity }, plan.plannedQuantity);
|
||||
|
||||
function choose(s: MaterialUsageStatus) {
|
||||
setStatus(s);
|
||||
setSaved(false);
|
||||
if (s === "fully_used") setQuantity(plan.plannedQuantity);
|
||||
if (s === "not_used") setQuantity(0);
|
||||
if (s === "partially_used" && (quantity <= 0 || quantity >= plan.plannedQuantity)) setQuantity(Math.max(0, Math.round((plan.plannedQuantity / 2) * 1000) / 1000));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setError(null);
|
||||
const problem = validateMaterialUsage({ usageStatus: status, quantity, deviationReason: reason }, plan.plannedQuantity);
|
||||
if (problem) {
|
||||
setError(problem.startsWith("reason") ? t("materials.reasonRequired") : t("materials.invalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const result = await submitOp({
|
||||
opType: "material.upsert",
|
||||
payload: { workOrderId, materialPlanId: plan.id, quantity, unit: plan.unit, usageStatus: status, deviationReason: deviates ? reason.trim() : null },
|
||||
});
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
setError(t(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={cn(card, "space-y-3")}>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold">{plan.name}</p>
|
||||
{plan.articleNumber && <p className="font-mono text-[13px] text-muted-foreground">{plan.articleNumber}</p>}
|
||||
<p className="text-[14px] text-muted-foreground">{t("materials.planned", { quantity: fmtQty(plan.plannedQuantity), unit: plan.unit })}</p>
|
||||
{plan.notes && <p className="mt-1 text-[14px]">{plan.notes}</p>}
|
||||
<p className="mt-1 text-[13px] font-semibold">
|
||||
{usage ? t("materials.recorded", { quantity: fmtQty(usage.quantity), unit: usage.unit }) : t("materials.open")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PLAN_STATUSES.map((s) => (
|
||||
<button key={s} type="button" aria-pressed={status === s} className={cn(chip(status === s), "px-2 text-[13px]")} onClick={() => choose(s)}>
|
||||
{t(`materials.usage.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{status !== "not_used" && (
|
||||
<Stepper
|
||||
value={quantity}
|
||||
onChange={(v) => {
|
||||
setQuantity(v);
|
||||
setSaved(false);
|
||||
}}
|
||||
label={`${t("materials.quantity")} (${plan.unit})`}
|
||||
decreaseLabel={t("materials.decrease")}
|
||||
increaseLabel={t("materials.increase")}
|
||||
/>
|
||||
)}
|
||||
{deviates && (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.reason")}</span>
|
||||
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<Feedback error={error} saved={saved} savedLabel={t("materials.saved")} />
|
||||
<button type="button" className={btnPrimary} disabled={busy} onClick={save}>
|
||||
{busy ? t("action.saving") : t("materials.save")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Additional (unplanned) material: name, optional article number, quantity, unit suggestions, reason. */
|
||||
export function AdditionalMaterialForm({ workOrderId }: { workOrderId: string }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [clientId, setClientId] = useState(() => newClientId());
|
||||
const [name, setName] = useState("");
|
||||
const [articleNumber, setArticleNumber] = useState("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [unit, setUnit] = useState<string>(UNIT_SUGGESTIONS[0]);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const problem = validateMaterialUsage({ usageStatus: "additional", quantity, name, deviationReason: reason }, null) ?? (unit.trim() ? null : "unit");
|
||||
if (problem) {
|
||||
setError(t("materials.invalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const result = await submitOp({
|
||||
opType: "material.upsert",
|
||||
payload: { workOrderId, clientId, name: name.trim(), articleNumber: articleNumber.trim() || null, quantity, unit: unit.trim(), usageStatus: "additional", deviationReason: reason.trim() },
|
||||
});
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
setError(t(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
setClientId(newClientId());
|
||||
setName("");
|
||||
setArticleNumber("");
|
||||
setQuantity(1);
|
||||
setReason("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className={cn(card, "space-y-3")}>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.name")}</span>
|
||||
<input className={inputClass} required maxLength={200} value={name} onChange={(e) => { setName(e.target.value); setSaved(false); }} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.articleNumber")}</span>
|
||||
<input className={inputClass} maxLength={100} value={articleNumber} onChange={(e) => setArticleNumber(e.target.value)} />
|
||||
</label>
|
||||
<Stepper value={quantity} onChange={setQuantity} label={t("materials.quantity")} decreaseLabel={t("materials.decrease")} increaseLabel={t("materials.increase")} />
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-[14px] font-semibold">{t("materials.unit")}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{UNIT_SUGGESTIONS.slice(0, 7).map((u) => (
|
||||
<button key={u} type="button" aria-pressed={unit === u} className={cn(chip(unit === u), "min-w-12")} onClick={() => setUnit(u)}>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input className={cn(inputClass, "mt-2")} list="field-unit-suggestions" maxLength={20} aria-label={t("materials.unit")} value={unit} onChange={(e) => setUnit(e.target.value)} />
|
||||
<datalist id="field-unit-suggestions">
|
||||
{UNIT_SUGGESTIONS.map((u) => (
|
||||
<option key={u} value={u} />
|
||||
))}
|
||||
</datalist>
|
||||
</fieldset>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.additionalReason")}</span>
|
||||
<textarea rows={2} required maxLength={2000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<Feedback error={error} saved={saved} savedLabel={t("materials.saved")} />
|
||||
<button type="submit" className={btnPrimary} disabled={busy}>
|
||||
{busy ? t("action.saving") : t("materials.add")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CircleCheck, TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops";
|
||||
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||
import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||
|
||||
type Draft = { kind: NoteKind; text: string; clientId: string };
|
||||
|
||||
const draftKey = (workOrderId: string) => `craftvia.field.noteDraft.${workOrderId}`;
|
||||
|
||||
function readDraft(workOrderId: string): Draft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(workOrderId));
|
||||
return raw ? (JSON.parse(raw) as Draft) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry
|
||||
* stays idempotent) is kept in localStorage until the server confirmed it (US-006).
|
||||
*/
|
||||
export function NoteForm({ workOrderId }: { workOrderId: string }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [draft, setDraft] = useState<Draft>({ kind: "work_done", text: "", clientId: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readDraft(workOrderId);
|
||||
// restore an unsent draft after reload / connection loss (client-only storage)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (stored) setDraft(stored);
|
||||
}, [workOrderId]);
|
||||
|
||||
function update(next: Partial<Draft>) {
|
||||
const value = { ...draft, ...next, clientId: draft.clientId || newClientId() };
|
||||
setDraft(value);
|
||||
setSaved(false);
|
||||
try {
|
||||
localStorage.setItem(draftKey(workOrderId), JSON.stringify(value));
|
||||
} catch {
|
||||
// storage unavailable (private mode) — the form still works
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!draft.text.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const clientId = draft.clientId || newClientId();
|
||||
const result = await submitOp({ opType: "note.create", payload: { workOrderId, clientId, kind: draft.kind, text: draft.text.trim() } });
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(draftKey(workOrderId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDraft({ kind: draft.kind, text: "", clientId: "" });
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-[14px] font-semibold">{t("notes.kindLabel")}</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{NOTE_KINDS.map((k) => (
|
||||
<button key={k} type="button" aria-pressed={draft.kind === k} className={chip(draft.kind === k)} onClick={() => update({ kind: k })}>
|
||||
{t(`notes.kind.${k}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("notes.text")}</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
maxLength={10000}
|
||||
required
|
||||
className={cn(inputClass, "py-3")}
|
||||
placeholder={t("notes.placeholder")}
|
||||
value={draft.text}
|
||||
onChange={(e) => update({ text: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<p className={noticeOk} role="status">
|
||||
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t("notes.saved")}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className={btnPrimary} disabled={busy || !draft.text.trim()}>
|
||||
{busy ? t("action.saving") : t("notes.save")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
window.addEventListener("online", cb);
|
||||
window.addEventListener("offline", cb);
|
||||
return () => {
|
||||
window.removeEventListener("online", cb);
|
||||
window.removeEventListener("offline", cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function useOnline(): boolean {
|
||||
return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
|
||||
}
|
||||
|
||||
/** Online/offline indicator (navigator.onLine) — text + icon, never colour alone. */
|
||||
export function OnlineBadge({ large = false }: { large?: boolean }) {
|
||||
const t = useTranslations("field.connection");
|
||||
const online = useOnline();
|
||||
const Icon = online ? Wifi : WifiOff;
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full font-semibold",
|
||||
large ? "px-3.5 py-2 text-[15px]" : "px-2.5 py-1 text-[12.5px]",
|
||||
online
|
||||
? "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]"
|
||||
: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]",
|
||||
)}
|
||||
>
|
||||
<Icon className={large ? "size-5" : "size-4"} aria-hidden />
|
||||
{online ? t("online") : t("offline")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { ChevronRight, Clock, MapPin, Navigation, Siren } 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";
|
||||
|
||||
/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
|
||||
export function OrderCard({ order }: { order: OrderCardData }) {
|
||||
const t = useTranslations("field.card");
|
||||
const locale = useLocale();
|
||||
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
|
||||
return (
|
||||
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", 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>
|
||||
<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") && (
|
||||
<p className="mt-1.5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
|
||||
<Siren className="size-4" aria-hidden />
|
||||
{order.isEmergency ? t("emergency") : order.priority === "urgent" ? t("urgent") : t("high")}
|
||||
</p>
|
||||
)}
|
||||
<dl className="mt-3 space-y-2 text-[15px]">
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<dd>{window ?? t("noDate")}</dd>
|
||||
</div>
|
||||
{order.address && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<dd className="flex-1">
|
||||
{order.siteName && <span className="block text-[13px] text-muted-foreground">{order.siteName}</span>}
|
||||
{order.address}
|
||||
</dd>
|
||||
{order.mapsUrl && (
|
||||
<a
|
||||
href={order.mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex min-h-12 min-w-12 items-center justify-center gap-1 rounded-xl border px-3 text-[13px] font-semibold text-primary"
|
||||
>
|
||||
<Navigation className="size-4" aria-hidden />
|
||||
{t("route")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<Link href={`/m/orders/${order.id}`} className={cn(btnPrimary, "mt-4")}>
|
||||
{t("open")}
|
||||
<ChevronRight className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops";
|
||||
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||
import { compressImage, currentPosition } from "@/lib/field/image";
|
||||
import { uploadFieldFile } from "@/lib/field/upload";
|
||||
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||
|
||||
type Option = { id: string; label: string };
|
||||
type Meta = { phase: PhotoPhase | null; photoRequirementId: string | null; checklistItemId: string | null; comment: string; withLocation: boolean };
|
||||
type Progress = { stage: "idle" } | { stage: "compressing" } | { stage: "uploading"; percent: number } | { stage: "attaching" };
|
||||
|
||||
const DIRECT_UPLOAD = /^image\/(jpeg|png|webp)$/;
|
||||
|
||||
/** Compress → upload (with progress) → photo.attach. Keeps the uploaded documentId for retries. */
|
||||
function usePhotoSave(workOrderId: string) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [progress, setProgress] = useState<Progress>({ stage: "idle" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const uploaded = useRef<{ file: File; documentId: string } | null>(null);
|
||||
|
||||
async function save(file: File, meta: Meta): Promise<boolean> {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
let documentId = uploaded.current?.file === file ? uploaded.current.documentId : null;
|
||||
if (!documentId) {
|
||||
setProgress({ stage: "compressing" });
|
||||
let image: Blob = file;
|
||||
let thumbnail: Blob | null = null;
|
||||
try {
|
||||
const out = await compressImage(file);
|
||||
image = out.image;
|
||||
thumbnail = out.thumbnail;
|
||||
} catch {
|
||||
if (!DIRECT_UPLOAD.test(file.type)) {
|
||||
setProgress({ stage: "idle" });
|
||||
setError(t("errors.image"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setProgress({ stage: "uploading", percent: 0 });
|
||||
const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
|
||||
const up = await uploadFieldFile({
|
||||
workOrderId,
|
||||
kind: "photo",
|
||||
clientId: newClientId(),
|
||||
file: image,
|
||||
fileName: thumbnail ? name : file.name || name,
|
||||
preview: thumbnail,
|
||||
onProgress: (percent) => setProgress({ stage: "uploading", percent }),
|
||||
});
|
||||
if (!up.ok) {
|
||||
setProgress({ stage: "idle" });
|
||||
setError(t(`errors.${up.error}`));
|
||||
return false;
|
||||
}
|
||||
documentId = up.documentId;
|
||||
uploaded.current = { file, documentId };
|
||||
}
|
||||
setProgress({ stage: "attaching" });
|
||||
const pos = meta.withLocation ? await currentPosition() : null;
|
||||
const result = await submitOp({
|
||||
opType: "photo.attach",
|
||||
payload: {
|
||||
workOrderId,
|
||||
clientId: newClientId(),
|
||||
documentId,
|
||||
phase: meta.phase,
|
||||
photoRequirementId: meta.photoRequirementId,
|
||||
checklistItemId: meta.checklistItemId,
|
||||
comment: meta.comment.trim() || null,
|
||||
takenAt: new Date(Math.min(file.lastModified || Date.now(), Date.now())).toISOString(),
|
||||
...(pos ?? {}),
|
||||
},
|
||||
});
|
||||
setProgress({ stage: "idle" });
|
||||
if (!isSuccess(result)) {
|
||||
setError(t(`errors.${errorKey(result)}`));
|
||||
return false;
|
||||
}
|
||||
uploaded.current = null;
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
const label =
|
||||
progress.stage === "compressing"
|
||||
? t("photos.compressing")
|
||||
: progress.stage === "uploading"
|
||||
? t("photos.uploading", { percent: progress.percent })
|
||||
: progress.stage === "attaching"
|
||||
? t("action.saving")
|
||||
: null;
|
||||
return { save, progress, label, error, saved, busy: progress.stage !== "idle" };
|
||||
}
|
||||
|
||||
function ProgressBar({ progress, label }: { progress: Progress; label: string | null }) {
|
||||
if (!label) return null;
|
||||
const percent = progress.stage === "uploading" ? progress.percent : progress.stage === "attaching" ? 100 : 5;
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="space-y-1.5">
|
||||
<p className="flex items-center gap-2 text-[14px] font-semibold">
|
||||
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
|
||||
{label}
|
||||
</p>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Quick camera button on the order detail: one tap opens the camera, the photo is saved as "during". */
|
||||
export function QuickPhotoButton({ workOrderId, label }: { workOrderId: string; label: string }) {
|
||||
const { save, progress, label: progressLabel, error, busy } = usePhotoSave(workOrderId);
|
||||
const t = useTranslations("field.photos");
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<div className="contents">
|
||||
<label className={cn("flex min-h-18 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl bg-cta px-2 text-[13px] font-semibold text-cta-foreground", busy && "opacity-60")}>
|
||||
{busy ? <LoaderCircle className="size-6 animate-spin" aria-hidden /> : <Camera className="size-6" aria-hidden />}
|
||||
{label}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
disabled={busy}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) setDone(await save(file, { phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false }));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{(busy || error || done) && (
|
||||
<div className="col-span-full">
|
||||
<ProgressBar progress={progress} label={progressLabel} />
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{done && !busy && !error && (
|
||||
<p className={noticeOk} role="status">
|
||||
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t("saved")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full photo capture: camera or gallery, phase, required photo, checklist item, comment, optional location. */
|
||||
export function PhotoCapture({ workOrderId, requirements, checklistItems }: { workOrderId: string; requirements: Option[]; checklistItems: Option[] }) {
|
||||
const t = useTranslations("field.photos");
|
||||
const { save, progress, label, error, saved, busy } = usePhotoSave(workOrderId);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [meta, setMeta] = useState<Meta>({ phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false });
|
||||
|
||||
const showFile = (f: File | null) => {
|
||||
setPreviewUrl((old) => {
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
return f ? URL.createObjectURL(f) : null;
|
||||
});
|
||||
setFile(f);
|
||||
};
|
||||
|
||||
const pick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (f) showFile(f);
|
||||
};
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<label className={cn(btnPrimary, "cursor-pointer")}>
|
||||
<Camera className="size-5" aria-hidden />
|
||||
{t("camera")}
|
||||
<input type="file" accept="image/*" capture="environment" className="sr-only" onChange={pick} />
|
||||
</label>
|
||||
<label className={cn(btnSecondary, "min-h-14 cursor-pointer")}>
|
||||
<ImageIcon className="size-5" aria-hidden />
|
||||
{t("gallery")}
|
||||
<input type="file" accept="image/*" className="sr-only" onChange={pick} />
|
||||
</label>
|
||||
</div>
|
||||
{saved && (
|
||||
<p className={noticeOk} role="status">
|
||||
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t("saved")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await save(file, meta)) {
|
||||
showFile(null);
|
||||
setMeta((m) => ({ ...m, comment: "", photoRequirementId: null, checklistItemId: null }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt={t("preview")} className="max-h-72 w-full rounded-xl bg-muted object-contain" />
|
||||
)}
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-[14px] font-semibold">{t("phaseLabel")}</legend>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PHOTO_PHASES.map((p) => (
|
||||
<button key={p} type="button" aria-pressed={meta.phase === p} className={chip(meta.phase === p)} onClick={() => setMeta({ ...meta, phase: p })}>
|
||||
{t(`phase.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{requirements.length > 0 && (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("requirement")}</span>
|
||||
<select className={inputClass} value={meta.photoRequirementId ?? ""} onChange={(e) => setMeta({ ...meta, photoRequirementId: e.target.value || null })}>
|
||||
<option value="">{t("none")}</option>
|
||||
{requirements.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{checklistItems.length > 0 && (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("checklistItem")}</span>
|
||||
<select className={inputClass} value={meta.checklistItemId ?? ""} onChange={(e) => setMeta({ ...meta, checklistItemId: e.target.value || null })}>
|
||||
<option value="">{t("none")}</option>
|
||||
{checklistItems.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("comment")}</span>
|
||||
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={meta.comment} onChange={(e) => setMeta({ ...meta, comment: e.target.value })} />
|
||||
</label>
|
||||
<label className="flex min-h-12 items-center gap-3 text-[15px]">
|
||||
<input type="checkbox" className="size-6 accent-[var(--primary)]" checked={meta.withLocation} onChange={(e) => setMeta({ ...meta, withLocation: e.target.checked })} />
|
||||
{t("location")}
|
||||
</label>
|
||||
<ProgressBar progress={progress} label={label} />
|
||||
{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}>
|
||||
<Upload className="size-5" aria-hidden />
|
||||
{t("save")}
|
||||
</button>
|
||||
<button type="button" className={btnSecondary} disabled={busy} onClick={() => showFile(null)}>
|
||||
{t("discard")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Ban, BadgeCheck, CalendarClock, CircleDot, FileSearch, Receipt, TriangleAlert, Truck, Wrench, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { toneClasses } from "./ui";
|
||||
|
||||
const ICONS: Record<StatusGroup, LucideIcon> = {
|
||||
new: CircleDot,
|
||||
planned: CalendarClock,
|
||||
en_route: Truck,
|
||||
in_progress: Wrench,
|
||||
documentation_incomplete: TriangleAlert,
|
||||
in_review: FileSearch,
|
||||
ready_for_billing: BadgeCheck,
|
||||
billed: Receipt,
|
||||
cancelled: Ban,
|
||||
};
|
||||
|
||||
/** Status group (Brandbook §12.3) as text + icon; the detailed status is appended when it differs. */
|
||||
export function StatusBadge({ status, large = false }: { status: WorkOrderStatus; large?: boolean }) {
|
||||
const t = useTranslations("field");
|
||||
const group = STATUS_GROUP[status];
|
||||
const Icon = ICONS[group];
|
||||
const groupLabel = t(`statusGroup.${group}`);
|
||||
const detail = t(`statusDetail.${status}`);
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 rounded-full font-semibold", large ? "px-3 py-1.5 text-[14px]" : "px-2.5 py-1 text-[12.5px]", toneClasses(group).badge)}>
|
||||
<Icon className={large ? "size-4.5" : "size-4"} aria-hidden />
|
||||
{groupLabel}
|
||||
{detail !== groupLabel && <span className="font-normal">· {detail}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { inputClass } from "./ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Quantity stepper with large +/− buttons (Spec §22: material with few inputs). */
|
||||
export function Stepper({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
decreaseLabel,
|
||||
increaseLabel,
|
||||
step = 1,
|
||||
min = 0,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
label: string;
|
||||
decreaseLabel: string;
|
||||
increaseLabel: string;
|
||||
step?: number;
|
||||
min?: number;
|
||||
}) {
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
const btn = "grid size-14 shrink-0 place-items-center rounded-xl border bg-card text-primary hover:bg-muted disabled:opacity-40";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className={btn} aria-label={decreaseLabel} disabled={value <= min} onClick={() => onChange(Math.max(min, round(value - step)))}>
|
||||
<Minus className="size-6" aria-hidden />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label={label}
|
||||
className={cn(inputClass, "min-h-14 text-center text-lg font-semibold")}
|
||||
value={String(value).replace(".", ",")}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value.replace(",", "."));
|
||||
if (Number.isFinite(n) && n >= min) onChange(round(n));
|
||||
else if (e.target.value.trim() === "") onChange(min);
|
||||
}}
|
||||
/>
|
||||
<button type="button" className={btn} aria-label={increaseLabel} onClick={() => onChange(round(value + step))}>
|
||||
<Plus className="size-6" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
/** Header of the order sub pages (max. two navigation levels below the order list). */
|
||||
export function SubPageHeader({ workOrderId, number, title, section }: { workOrderId: string; number: string; title: string; section: string }) {
|
||||
const t = useTranslations("field.detail");
|
||||
return (
|
||||
<div className="px-4 pt-3">
|
||||
<Link href={`/m/orders/${workOrderId}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
|
||||
<ChevronLeft className="size-5" aria-hidden />
|
||||
{t("backToOrder")}
|
||||
</Link>
|
||||
<p className="mt-1 font-mono text-[13px] text-muted-foreground">
|
||||
{number} · {title}
|
||||
</p>
|
||||
<h1 className="text-[24px]">{section}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { correctTime } from "@/server/actions/field/time";
|
||||
import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui";
|
||||
|
||||
/** ISO → value of <input type="datetime-local"> in the device time zone. */
|
||||
function toLocalInput(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** Manual correction of a time segment (Spec §12.2) — only rendered for users with field:correct_time. */
|
||||
export function TimeCorrectionForm({ workOrderId, entry }: { workOrderId: string; entry: { id: 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 [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(btnSecondary, "min-h-12")}
|
||||
onClick={() => {
|
||||
// inputs are prefilled only on the client (device time zone), never during SSR
|
||||
setStart(toLocalInput(entry.startedAt));
|
||||
setEnd(entry.endedAt ? toLocalInput(entry.endedAt) : "");
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("time.correct")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (reason.trim().length < 3) {
|
||||
setError(t("time.reasonHint"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const result = await correctTime({
|
||||
workOrderId,
|
||||
timeEntryId: entry.id,
|
||||
startedAt: new Date(start).toISOString(),
|
||||
endedAt: end ? new Date(end).toISOString() : null,
|
||||
reason: reason.trim(),
|
||||
}).catch(() => ({ ok: false as const, error: "failed" as const }));
|
||||
setBusy(false);
|
||||
if (!result.ok) {
|
||||
setError(t(`errors.${result.error === "failed" ? "internal" : result.error}`));
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3 rounded-xl bg-muted p-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<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" className={inputClass} value={end} min={start} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.reason")}</span>
|
||||
<textarea rows={2} required minLength={3} maxLength={1000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
<span className="mt-1 block text-[13px] text-muted-foreground">{t("time.reasonHint")}</span>
|
||||
</label>
|
||||
{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("time.save")}
|
||||
</button>
|
||||
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setOpen(false)}>
|
||||
{t("photos.discard")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { StatusGroup } from "@/lib/work-orders/status";
|
||||
import { STATUS_GROUP_TONE } from "@/lib/work-orders/status";
|
||||
|
||||
/** Shared class names of the mobile field UI (touch targets ≥ 48 px, colours only via tokens). */
|
||||
|
||||
export const btnPrimary =
|
||||
"inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-cta px-5 font-heading text-base font-semibold text-cta-foreground transition-opacity hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
export const btnSecondary =
|
||||
"inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-primary transition-colors hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
export function chip(active: boolean) {
|
||||
return cn(
|
||||
"inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border px-3.5 text-[14px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-muted",
|
||||
);
|
||||
}
|
||||
|
||||
export const card = "rounded-xl border bg-card p-4 shadow-card";
|
||||
|
||||
export const inputClass =
|
||||
"min-h-12 w-full rounded-xl border border-input bg-card px-3.5 text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
const TONE_CLASSES = {
|
||||
neutral: { badge: "bg-muted text-muted-foreground", edge: "border-l-[var(--brand-stahlgrau)]" },
|
||||
info: { badge: "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]", edge: "border-l-[var(--info)]" },
|
||||
accent: { badge: "bg-[color-mix(in_oklch,var(--ui-accent)_14%,transparent)] text-foreground", edge: "border-l-[var(--ui-accent)]" },
|
||||
warning: { badge: "bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-[var(--warn)]", edge: "border-l-[var(--warn)]" },
|
||||
success: { badge: "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]", edge: "border-l-[var(--ok)]" },
|
||||
danger: { badge: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]", edge: "border-l-[var(--risk)]" },
|
||||
} as const;
|
||||
|
||||
export function toneClasses(group: StatusGroup) {
|
||||
return TONE_CLASSES[STATUS_GROUP_TONE[group]];
|
||||
}
|
||||
|
||||
export const noticeError = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--risk)_10%,transparent)] px-3.5 py-3 text-[14px] text-[var(--risk)]";
|
||||
export const noticeOk = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--ok)_10%,transparent)] px-3.5 py-3 text-[14px] text-[var(--ok)]";
|
||||
export const noticeWarn = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--warn)_12%,transparent)] px-3.5 py-3 text-[14px] text-foreground";
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react";
|
||||
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||
import { uploadFieldFile } from "@/lib/field/upload";
|
||||
import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui";
|
||||
|
||||
export const MAX_RECORDING_SECONDS = 300;
|
||||
|
||||
function pickMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === "undefined") return undefined;
|
||||
for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) {
|
||||
if (MediaRecorder.isTypeSupported(type)) return type;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const extensionFor = (mime: string) => (mime.includes("mp4") ? "m4a" : mime.includes("ogg") ? "ogg" : "webm");
|
||||
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
|
||||
/** Voice note (Spec §15.1): MediaRecorder (webm/opus, mp4 on iOS), max. 5 minutes, then upload + voice.attach. */
|
||||
export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
|
||||
const t = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState<"idle" | "recording" | "recorded" | "saving">("idle");
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [clip, setClip] = useState<{ blob: Blob; url: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [percent, setPercent] = useState(0);
|
||||
const recorder = useRef<MediaRecorder | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearInterval(timer.current);
|
||||
recorder.current?.stream.getTracks().forEach((tr) => tr.stop());
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => () => (clip ? URL.revokeObjectURL(clip.url) : undefined), [clip]);
|
||||
|
||||
const supported = typeof window === "undefined" || (typeof MediaRecorder !== "undefined" && !!navigator.mediaDevices?.getUserMedia);
|
||||
|
||||
function stop() {
|
||||
if (timer.current) clearInterval(timer.current);
|
||||
timer.current = null;
|
||||
if (recorder.current?.state === "recording") recorder.current.stop();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = pickMimeType();
|
||||
const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
const chunks: BlobPart[] = [];
|
||||
rec.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data);
|
||||
rec.onstop = () => {
|
||||
stream.getTracks().forEach((tr) => tr.stop());
|
||||
const blob = new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" });
|
||||
setClip({ blob, url: URL.createObjectURL(blob) });
|
||||
setState("recorded");
|
||||
};
|
||||
recorder.current = rec;
|
||||
rec.start(1000);
|
||||
setSeconds(0);
|
||||
setState("recording");
|
||||
timer.current = setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s + 1 >= MAX_RECORDING_SECONDS) stop();
|
||||
return Math.min(s + 1, MAX_RECORDING_SECONDS);
|
||||
});
|
||||
}, 1000);
|
||||
} catch {
|
||||
setError(t("voice.denied"));
|
||||
setState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!clip) return;
|
||||
setState("saving");
|
||||
setError(null);
|
||||
const type = clip.blob.type || "audio/webm";
|
||||
const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent });
|
||||
if (!up.ok) {
|
||||
setError(t(`errors.${up.error}`));
|
||||
setState("recorded");
|
||||
return;
|
||||
}
|
||||
const result = await submitOp({
|
||||
opType: "voice.attach",
|
||||
payload: { workOrderId, clientId: newClientId(), documentId: up.documentId, durationSeconds: Math.min(seconds, MAX_RECORDING_SECONDS), recordedAt: new Date().toISOString() },
|
||||
});
|
||||
if (!isSuccess(result)) {
|
||||
setError(t(`errors.${errorKey(result)}`));
|
||||
setState("recorded");
|
||||
return;
|
||||
}
|
||||
setClip(null);
|
||||
setSaved(true);
|
||||
setState("idle");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (!supported) return <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{state === "idle" && (
|
||||
<button type="button" className={btnSecondary} onClick={start}>
|
||||
<Mic className="size-5" aria-hidden />
|
||||
{t("voice.record")}
|
||||
</button>
|
||||
)}
|
||||
{state === "recording" && (
|
||||
<>
|
||||
<p role="status" aria-live="polite" className="flex items-center gap-2 text-[15px] font-semibold text-[var(--risk)]">
|
||||
<span className="size-3 animate-pulse rounded-full bg-[var(--risk)]" aria-hidden />
|
||||
{t("voice.recording", { time: clock(seconds) })}
|
||||
</p>
|
||||
<button type="button" className={btnPrimary} onClick={stop}>
|
||||
<Square className="size-5" aria-hidden />
|
||||
{t("voice.stop")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(state === "recorded" || state === "saving") && clip && (
|
||||
<>
|
||||
<audio controls src={clip.url} className="w-full" />
|
||||
{state === "saving" && (
|
||||
<p role="status" className="flex items-center gap-2 text-[14px] font-semibold">
|
||||
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
|
||||
{t("photos.uploading", { percent })}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className={btnPrimary} onClick={save} disabled={state === "saving"}>
|
||||
<Upload className="size-5" aria-hidden />
|
||||
{t("voice.save")}
|
||||
</button>
|
||||
<button type="button" className={btnSecondary} onClick={() => { setClip(null); setState("idle"); }} disabled={state === "saving"}>
|
||||
{t("voice.discard")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<p className={noticeOk} role="status">
|
||||
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t("voice.saved")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user