Merge lane/berichte in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReportActionState } from "@/lib/reports/action-state";
|
||||
import { BlockerList } from "./blocker-list";
|
||||
|
||||
const FIELD_MESSAGES: Record<string, string> = {
|
||||
reason: "errors.reasonRequired",
|
||||
signerName: "errors.signerRequired",
|
||||
image: "errors.imageRequired",
|
||||
};
|
||||
|
||||
/** Inline feedback for report actions (errors directly at the form, Brandbook §12.5). */
|
||||
export function ActionMessage({ state, okText }: { state: ReportActionState; okText?: string }) {
|
||||
const t = useTranslations("reports");
|
||||
if (state.status === "ok") {
|
||||
if (!okText) return null;
|
||||
return (
|
||||
<p role="status" className="flex items-center gap-2 text-[13px] font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{okText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status !== "error") return null;
|
||||
if (state.blockers?.length) return <BlockerList blockers={state.blockers} title={t("errors.blocked")} />;
|
||||
const key = state.field && FIELD_MESSAGES[state.field] ? FIELD_MESSAGES[state.field] : state.code === "forbidden" && state.field === undefined ? "errors.forbidden" : `errors.${state.code}`;
|
||||
return (
|
||||
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||
<XCircle className="size-4 shrink-0" aria-hidden />
|
||||
{t(key)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
|
||||
/** Structured list of what is still missing (checklist, required photos, running time, required fields). */
|
||||
export function BlockerList({ blockers, title }: { blockers: CompletionBlocker[]; title?: string }) {
|
||||
const t = useTranslations("reports");
|
||||
if (!blockers.length) return null;
|
||||
const label = (b: CompletionBlocker) => {
|
||||
switch (b.kind) {
|
||||
case "checklist_item":
|
||||
return t("blocker.checklist_item", { label: b.label });
|
||||
case "photo_requirement":
|
||||
return t("blocker.photo_requirement", { label: b.label });
|
||||
case "running_session":
|
||||
return t("blocker.running_session");
|
||||
case "missing_field":
|
||||
return t("blocker.missing_field", { field: t.has(`texts.${b.field}`) ? t(`texts.${b.field}`) : b.field });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-[var(--warn)] bg-card p-4">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--warn)]">
|
||||
<AlertTriangle className="size-4.5" aria-hidden />
|
||||
{title ?? t("section.blockers")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-[13.5px]">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span aria-hidden>•</span>
|
||||
{label(b)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { FilePlus2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { createReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "../action-message";
|
||||
|
||||
export function CreateReportForm({ workOrderId, type, label, disabled }: { workOrderId: string; type: "daily" | "completion"; label: string; disabled?: boolean }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(createReportAction, IDLE);
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") router.refresh();
|
||||
}, [state, router]);
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="workOrderId" value={workOrderId} />
|
||||
<input type="hidden" name="type" value={type} />
|
||||
<Button type="submit" disabled={pending || disabled} className="h-12 w-full text-[15px]">
|
||||
<FilePlus2 aria-hidden />
|
||||
{label}
|
||||
</Button>
|
||||
<ActionMessage state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowRight, Save, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts, type ReportType } from "@/lib/reports/content";
|
||||
import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "../action-message";
|
||||
|
||||
/**
|
||||
* Mobile report editor: technician checks/extends the prefilled texts.
|
||||
* Daily report: save or submit directly (signature optional). Completion: save and continue to signature.
|
||||
*/
|
||||
export function ReportEditor({ reportId, type, texts, signHref }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string }) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [intent, setIntent] = useState<"save" | "sign">("save");
|
||||
const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE);
|
||||
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
|
||||
const required = new Set<string>(REPORT_REQUIRED_TEXTS[type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState.status === "ok" && intent === "sign" && signHref) router.push(signHref);
|
||||
}, [saveState, intent, router, signHref]);
|
||||
useEffect(() => {
|
||||
if (submitState.status === "ok") router.refresh();
|
||||
}, [submitState, router]);
|
||||
|
||||
return (
|
||||
<form action={save} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.edit")}</h2>
|
||||
{REPORT_TEXT_FIELDS.map((f) => (
|
||||
<div key={f}>
|
||||
<Label htmlFor={`rt-${f}`} className="text-[13px]">
|
||||
{t(`texts.${f}`)}
|
||||
{required.has(f) ? " *" : ""}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`rt-${f}`}
|
||||
name={f}
|
||||
defaultValue={texts[f]}
|
||||
maxLength={TEXT_MAX}
|
||||
rows={f === "workPerformed" ? 5 : 2}
|
||||
className="mt-1 min-h-12 text-base"
|
||||
aria-invalid={submitState.status === "error" && submitState.blockers?.some((b) => b.kind === "missing_field" && b.field === f)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<ActionMessage state={saveState} okText={intent === "save" ? t("mobile.saved") : undefined} />
|
||||
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="submit" variant="outline" disabled={saving || submitting} onClick={() => setIntent("save")} className="h-12 flex-1 text-[15px]">
|
||||
<Save aria-hidden />
|
||||
{t("mobile.save")}
|
||||
</Button>
|
||||
{type === "completion" && signHref ? (
|
||||
<Button type="submit" disabled={saving || submitting} onClick={() => setIntent("sign")} className="h-12 flex-1 text-[15px]">
|
||||
{t("mobile.toSign")}
|
||||
<ArrowRight aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" formAction={submit} disabled={saving || submitting} className="h-12 flex-1 text-[15px]">
|
||||
<Send aria-hidden />
|
||||
{t("mobile.submit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { ReportContent } from "@/lib/reports/content";
|
||||
import { ReportView } from "../report-view";
|
||||
|
||||
/** Mobile read-only review of the report as the customer/office will see it. */
|
||||
export async function ReportReview({ content, reportId, timeZone }: { content: ReportContent; reportId: string; timeZone: string }) {
|
||||
const t = await getTranslations("reports");
|
||||
return (
|
||||
<section aria-label={t("mobile.review")} className="space-y-2">
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.review")}</h2>
|
||||
<ReportView content={content} reportId={reportId} timeZone={timeZone} compact />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { REPORT_EDITABLE, type ReportStatus } from "@/lib/reports/content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { getMobileReportState } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
import { BlockerList } from "../blocker-list";
|
||||
import { ReportStatusBadge } from "../status-badge";
|
||||
import { StepIndicator } from "../step-indicator";
|
||||
import { CreateReportForm } from "./create-report-form";
|
||||
import { ReportEditor } from "./report-editor";
|
||||
import { ReportReview } from "./report-review";
|
||||
|
||||
/** /m/orders/[id]/report — blockers → create → check/extend → (completion) continue to signature. */
|
||||
export async function ReportScreen({ workOrderId, type }: { workOrderId: string; type: "daily" | "completion" }) {
|
||||
const t = await getTranslations("reports");
|
||||
const ctx = await readCtx();
|
||||
let state: Awaited<ReturnType<typeof getMobileReportState>>;
|
||||
try {
|
||||
state = await getMobileReportState(ctx, workOrderId, type);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
const { workOrder, report, content, blockers, timeZone } = state;
|
||||
const base = `/m/orders/${workOrderId}`;
|
||||
const editable = report ? REPORT_EDITABLE.includes(report.status as ReportStatus) : false;
|
||||
const steps = [t("mobile.stepReview"), t("mobile.stepEdit"), t("mobile.stepSign"), t("mobile.stepSubmit")];
|
||||
const tab = (active: boolean) =>
|
||||
cn("flex h-12 flex-1 items-center justify-center rounded-lg border text-[14px] font-semibold", active ? "border-[var(--ui-accent)] text-foreground" : "text-muted-foreground");
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 space-y-4 p-4">
|
||||
<Link href={base} className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("mobile.backToOrder")}
|
||||
</Link>
|
||||
<header>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{workOrder.number} · {workOrder.title}
|
||||
</p>
|
||||
<h1 className="text-[22px]">{t("mobile.title")}</h1>
|
||||
</header>
|
||||
|
||||
<nav className="flex gap-2" aria-label={t("field.type")}>
|
||||
<Link href={`${base}/report?type=completion`} className={tab(type === "completion")} aria-current={type === "completion" ? "page" : undefined}>
|
||||
{t("type.completion")}
|
||||
</Link>
|
||||
<Link href={`${base}/report?type=daily`} className={tab(type === "daily")} aria-current={type === "daily" ? "page" : undefined}>
|
||||
{t("type.daily")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{!report && (
|
||||
<section className="shadow-card space-y-3 rounded-xl border bg-card p-4">
|
||||
<p className="text-[14px]">{type === "daily" ? t("mobile.dailyHint") : t("mobile.completionHint")}</p>
|
||||
{type === "completion" && blockers.length > 0 && (
|
||||
<>
|
||||
<BlockerList blockers={blockers} />
|
||||
<p className="text-[13px] text-muted-foreground">{t("mobile.blockersHint")}</p>
|
||||
</>
|
||||
)}
|
||||
<CreateReportForm
|
||||
workOrderId={workOrderId}
|
||||
type={type}
|
||||
label={type === "daily" ? t("mobile.createDaily") : t("mobile.createCompletion")}
|
||||
disabled={type === "completion" && blockers.length > 0}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report && content && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReportStatusBadge status={report.status as ReportStatus} label={t(`status.${report.status}`)} />
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{t(`type.${report.type}`)} {content.reportNumber} · {t("field.version")} {report.version}
|
||||
</span>
|
||||
</div>
|
||||
{report.status === "rejected" && report.rejectionReason && (
|
||||
<p role="alert" className="rounded-xl border border-[var(--risk)] bg-card p-3 text-[14px] text-[var(--risk)]">
|
||||
{t("mobile.rejected", { reason: report.rejectionReason })}
|
||||
</p>
|
||||
)}
|
||||
{type === "completion" && editable && (
|
||||
<StepIndicator steps={steps} current={2} label={t("mobile.stepOf", { current: 2, total: steps.length })} />
|
||||
)}
|
||||
{editable && blockers.length > 0 && <BlockerList blockers={blockers} />}
|
||||
{editable ? (
|
||||
<ReportEditor reportId={report.id} type={report.type} texts={content.texts} signHref={type === "completion" ? `${base}/sign` : undefined} />
|
||||
) : (
|
||||
<p role="status" className="shadow-card rounded-xl border bg-card p-3 text-[14px]">
|
||||
{report.status === "approved" || report.status === "submitted" || report.status === "team_approved" ? t("mobile.submitted") : t("mobile.readOnly")}
|
||||
</p>
|
||||
)}
|
||||
<ReportReview content={content} reportId={report.id} timeZone={timeZone} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckCircle2, PenLine, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureOutcome } from "@/lib/reports/content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { captureSignatureAction } from "@/server/actions/reports/signature";
|
||||
import { submitReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "../action-message";
|
||||
import { SignaturePad } from "../signature-pad";
|
||||
|
||||
/**
|
||||
* Mobile completion step 3+4: signature or documented reason (Spec §18.2), then submit.
|
||||
*/
|
||||
export function SignFlow({
|
||||
reportId,
|
||||
reportNumber,
|
||||
orderNumber,
|
||||
dateLabel,
|
||||
canNotRequired,
|
||||
existing,
|
||||
editable,
|
||||
doneHref,
|
||||
}: {
|
||||
reportId: string;
|
||||
reportNumber: string;
|
||||
orderNumber: string;
|
||||
dateLabel: string;
|
||||
canNotRequired: boolean;
|
||||
existing: { outcome: SignatureOutcome; signerName: string | null; reason: string | null } | null;
|
||||
editable: boolean;
|
||||
doneHref: string;
|
||||
}) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [outcome, setOutcome] = useState<SignatureOutcome>(existing && existing.outcome !== "signed" ? existing.outcome : "signed");
|
||||
const [png, setPng] = useState<string | null>(null);
|
||||
const [sigState, capture, capturing] = useActionState(captureSignatureAction, IDLE);
|
||||
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
|
||||
const onPad = useCallback((v: string | null) => setPng(v), []);
|
||||
const confirmationText = t("sign.confirmation", { reportNumber, orderNumber, date: dateLabel });
|
||||
const signed = existing?.outcome === "signed";
|
||||
const options = SIGNATURE_OUTCOMES.filter((o) => o !== "not_required" || canNotRequired);
|
||||
|
||||
useEffect(() => {
|
||||
if (sigState.status === "ok") router.refresh();
|
||||
}, [sigState, router]);
|
||||
useEffect(() => {
|
||||
if (submitState.status === "ok") router.push(doneHref);
|
||||
}, [submitState, router, doneHref]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{signed ? (
|
||||
<p role="status" className="shadow-card flex items-center gap-2 rounded-xl border bg-card p-4 text-[14px] font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-5" aria-hidden />
|
||||
{t("sign.alreadySigned")} {existing?.signerName ? `· ${existing.signerName}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<form action={capture} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<input type="hidden" name="outcome" value={outcome} />
|
||||
<input type="hidden" name="confirmationText" value={confirmationText} />
|
||||
<input type="hidden" name="signaturePng" value={outcome === "signed" ? (png ?? "") : ""} />
|
||||
|
||||
<fieldset>
|
||||
<legend className="font-heading text-[15px] font-semibold">{t("sign.outcomeLabel")}</legend>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{options.map((o) => (
|
||||
<label
|
||||
key={o}
|
||||
className={cn(
|
||||
"flex min-h-12 cursor-pointer items-center gap-3 rounded-lg border px-3 text-[14px]",
|
||||
outcome === o && "border-[var(--ui-accent)] font-semibold",
|
||||
)}
|
||||
>
|
||||
<input type="radio" name="outcomeChoice" value={o} checked={outcome === o} onChange={() => setOutcome(o)} className="size-5 accent-[var(--ui-accent)]" />
|
||||
{t(`outcome.${o}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{outcome === "signed" && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="sig-name">{t("sign.signerName")} *</Label>
|
||||
<Input id="sig-name" name="signerName" required autoComplete="name" className="mt-1 h-12 text-base" aria-invalid={sigState.status === "error" && sigState.field === "signerName"} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-role">{t("sign.signerRole")}</Label>
|
||||
<Input id="sig-role" name="signerRole" className="mt-1 h-12 text-base" />
|
||||
</div>
|
||||
</div>
|
||||
<SignaturePad onChange={onPad} labels={{ clear: t("sign.clear"), padLabel: t("sign.padLabel"), padEmpty: t("sign.padEmpty") }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(SIGNATURE_REASON_REQUIRED as readonly string[]).includes(outcome) && (
|
||||
<div>
|
||||
<Label htmlFor="sig-reason">{t("sign.reason")} *</Label>
|
||||
<Textarea id="sig-reason" name="reason" required rows={3} defaultValue={existing?.reason ?? ""} className="mt-1 min-h-24 text-base" aria-invalid={sigState.status === "error" && sigState.field === "reason"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="rounded-lg bg-muted p-3 text-[12.5px] text-muted-foreground">{confirmationText}</p>
|
||||
<ActionMessage state={sigState} okText={t("sign.saved")} />
|
||||
<Button type="submit" disabled={capturing || (outcome === "signed" && !png)} className="h-12 w-full text-[15px]">
|
||||
<PenLine aria-hidden />
|
||||
{t("sign.save")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<form action={submit} className="space-y-2">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
{!existing && <p className="text-[13px] text-[var(--warn)]">{t("mobile.signatureMissing")}</p>}
|
||||
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
||||
<Button type="submit" disabled={submitting || !existing} className="h-12 w-full text-[15px]">
|
||||
<Send aria-hidden />
|
||||
{t("mobile.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { REPORT_EDITABLE, type ReportStatus } from "@/lib/reports/content";
|
||||
import { can, ServiceError } from "@/server/services/context";
|
||||
import { getMobileReportState } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
import { ReportStatusBadge } from "../status-badge";
|
||||
import { StepIndicator } from "../step-indicator";
|
||||
import { SignFlow } from "./sign-flow";
|
||||
|
||||
/** /m/orders/[id]/sign — signature or documented reason, then submit the completion report. */
|
||||
export async function SignScreen({ workOrderId }: { workOrderId: string }) {
|
||||
const t = await getTranslations("reports");
|
||||
const format = await getFormatter();
|
||||
const ctx = await readCtx();
|
||||
let state: Awaited<ReturnType<typeof getMobileReportState>>;
|
||||
try {
|
||||
state = await getMobileReportState(ctx, workOrderId, "completion");
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
const { workOrder, report, content, timeZone } = state;
|
||||
const base = `/m/orders/${workOrderId}`;
|
||||
const steps = [t("mobile.stepReview"), t("mobile.stepEdit"), t("mobile.stepSign"), t("mobile.stepSubmit")];
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 space-y-4 p-4">
|
||||
<Link href={`${base}/report?type=completion`} className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("mobile.review")}
|
||||
</Link>
|
||||
<header>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{workOrder.number} · {workOrder.title}
|
||||
</p>
|
||||
<h1 className="text-[22px]">{t("sign.title")}</h1>
|
||||
</header>
|
||||
|
||||
{!report || !content ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-4 text-[14px]">
|
||||
{t("sign.noReport")}{" "}
|
||||
<Link href={`${base}/report?type=completion`} className="font-semibold underline">
|
||||
{t("mobile.createCompletion")}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<StepIndicator steps={steps} current={3} label={t("mobile.stepOf", { current: 3, total: steps.length })} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReportStatusBadge status={report.status as ReportStatus} label={t(`status.${report.status}`)} />
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{content.reportNumber} · {t("field.version")} {report.version}
|
||||
</span>
|
||||
</div>
|
||||
<SignFlow
|
||||
reportId={report.id}
|
||||
reportNumber={content.reportNumber}
|
||||
orderNumber={workOrder.number}
|
||||
dateLabel={format.dateTime(new Date(), { dateStyle: "medium", timeZone })}
|
||||
canNotRequired={!workOrder.signatureRequired || can(ctx, "report:approve")}
|
||||
existing={content.signature ? { outcome: content.signature.outcome, signerName: content.signature.signerName, reason: content.signature.reason } : null}
|
||||
editable={REPORT_EDITABLE.includes(report.status as ReportStatus)}
|
||||
doneHref={`${base}/report?type=completion`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { rejectReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "./action-message";
|
||||
|
||||
export function RejectForm({ reportId, closeHref }: { reportId: string; closeHref: string }) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(rejectReportAction, IDLE);
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") {
|
||||
router.push(closeHref);
|
||||
router.refresh();
|
||||
}
|
||||
}, [state, router, closeHref]);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3 p-5">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<div>
|
||||
<Label htmlFor="reject-reason">{t("actions.rejectReason")} *</Label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
className="mt-1 min-h-28"
|
||||
placeholder={t("actions.rejectPlaceholder")}
|
||||
aria-invalid={state.status === "error" && state.field === "reason"}
|
||||
/>
|
||||
</div>
|
||||
<ActionMessage state={state} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-11 px-4" nativeButton={false} render={<Link href={closeHref} scroll={false} />}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending} className="h-11 px-4">
|
||||
{t("actions.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { CheckCircle2, Circle } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { REPORT_TEXT_FIELDS, splitMinutes, type MaterialLine, type ReportContent } from "@/lib/reports/content";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Structured, read-only rendering of a report snapshot (backoffice detail + mobile review).
|
||||
* Images are served through /api/v1/reports/:id/files/:documentId (authorization per report).
|
||||
*/
|
||||
export async function ReportView({ content: c, reportId, timeZone, compact = false }: { content: ReportContent; reportId: string; timeZone: string; compact?: boolean }) {
|
||||
const t = await getTranslations("reports");
|
||||
const format = await getFormatter();
|
||||
const day = (key: string) => format.dateTime(new Date(`${key}T00:00:00Z`), { dateStyle: "medium", timeZone: "UTC" });
|
||||
const dateTime = (iso: string) => format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short", timeZone });
|
||||
const duration = (m: number) => {
|
||||
const s = splitMinutes(m);
|
||||
return t("time.hoursMinutes", { hours: s.hours, minutes: String(s.minutes).padStart(2, "0") });
|
||||
};
|
||||
const addr = (a: { line1: string | null; line2: string | null }) => [a.line1, a.line2].filter(Boolean).join(", ");
|
||||
const fileUrl = (documentId: string) => `/api/v1/reports/${reportId}/files/${documentId}`;
|
||||
const card = "shadow-card rounded-xl border bg-card p-4 md:p-5";
|
||||
const h2 = "font-heading text-[15px] font-semibold";
|
||||
|
||||
const kv: Array<[string, string | null | undefined]> = [
|
||||
[t("field.customer"), [c.customer.name, addr(c.customer.address)].filter(Boolean).join(", ")],
|
||||
[t("field.customerNumber"), c.customer.number],
|
||||
[t("field.site"), c.site ? [c.site.name, addr(c.site.address)].filter(Boolean).join(", ") : null],
|
||||
[t("field.contact"), c.contact ? [c.contact.name, c.contact.role, c.contact.phone, c.contact.email].filter(Boolean).join(" · ") : null],
|
||||
[t("field.orderNumber"), [c.workOrder.number, c.workOrder.externalOrderNumber].filter(Boolean).join(" / ")],
|
||||
[t("field.orderType"), c.workOrder.orderType],
|
||||
[t("field.workOrder"), c.workOrder.title],
|
||||
[t("field.workDates"), c.workDates.map(day).join(", ")],
|
||||
[t("field.staff"), c.staff.map((s) => s.name).join(", ")],
|
||||
[t("field.technician"), c.technician?.name],
|
||||
];
|
||||
|
||||
const materialGroups: Array<[string, MaterialLine[]]> = [
|
||||
[t("materials.used"), c.materials.used],
|
||||
[t("materials.notUsed"), c.materials.notUsed],
|
||||
[t("materials.additional"), c.materials.additional],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className={card} aria-labelledby={`rv-head-${reportId}`}>
|
||||
<h2 id={`rv-head-${reportId}`} className={h2}>
|
||||
{t("section.header")}
|
||||
</h2>
|
||||
<dl className={cn("mt-3 grid gap-x-6 gap-y-2.5", compact ? "grid-cols-1" : "sm:grid-cols-2")}>
|
||||
{kv
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{k}</dt>
|
||||
<dd className="text-[13.5px]">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{c.workOrder.description ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-[11.5px] font-semibold text-muted-foreground">{t("field.description")}</p>
|
||||
<p className="text-[13.5px] whitespace-pre-wrap">{c.workOrder.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.time")}</h2>
|
||||
{c.time.entries.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{t("time.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11.5px] text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-semibold">{t("time.person")}</th>
|
||||
<th className="py-1.5 pr-3 font-semibold">{t("time.type")}</th>
|
||||
<th className="py-1.5 text-right font-semibold">{t("time.duration")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{c.time.entries.map((e) => (
|
||||
<tr key={`${e.userId}-${e.type}`} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-3">{e.name}</td>
|
||||
<td className="py-1.5 pr-3">{t(`timeType.${e.type}`)}</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap">{duration(e.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-2 font-semibold">
|
||||
{t("time.total")}
|
||||
</td>
|
||||
<td className="pt-2 text-right font-semibold whitespace-nowrap">{duration(c.time.totalMinutes)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{c.time.hasRunningEntries ? <p className="mt-2 text-[12px] text-[var(--warn)]">{t("time.running", { time: dateTime(c.generatedAt) })}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.texts")}</h2>
|
||||
<dl className="mt-3 space-y-3">
|
||||
{REPORT_TEXT_FIELDS.map((f) => (
|
||||
<div key={f}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{t(`texts.${f}`)}</dt>
|
||||
<dd className={cn("text-[13.5px] whitespace-pre-wrap", !c.texts[f].trim() && "text-muted-foreground")}>{c.texts[f].trim() || "—"}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.materials")}</h2>
|
||||
{materialGroups.every(([, l]) => l.length === 0) ? <p className="mt-2 text-[13px] text-muted-foreground">{t("materials.empty")}</p> : null}
|
||||
{materialGroups
|
||||
.filter(([, lines]) => lines.length)
|
||||
.map(([label, lines]) => (
|
||||
<div key={label} className="mt-3">
|
||||
<h3 className="text-[12.5px] font-semibold">{label}</h3>
|
||||
<ul className="mt-1.5 divide-y text-[13px]">
|
||||
{lines.map((m, i) => (
|
||||
<li key={`${m.usageId ?? m.planId ?? i}`} className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 py-1.5">
|
||||
<span>
|
||||
{m.name}
|
||||
{m.articleNumber ? <span className="text-muted-foreground"> · {m.articleNumber}</span> : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{m.plannedQuantity ? `${t("materials.planned")} ${m.plannedQuantity} ${m.unit}` : ""}
|
||||
{m.plannedQuantity && m.actualQuantity ? " · " : ""}
|
||||
{m.actualQuantity ? `${t("materials.actual")} ${m.actualQuantity} ${m.unit}` : ""}
|
||||
{!m.documented ? t("materials.undocumented") : ""}
|
||||
</span>
|
||||
{m.deviation ? (
|
||||
<span className="w-full text-[12px] font-semibold text-[var(--warn)]">
|
||||
{t("materials.deviation")}
|
||||
{m.status ? ` · ${t(`materialStatus.${m.status}`)}` : ""}
|
||||
{m.deviationReason ? ` · ${m.deviationReason}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{c.checklist.length ? (
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.checklist")}</h2>
|
||||
<ul className="mt-2 space-y-1.5 text-[13px]">
|
||||
{c.checklist.map((i, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
{i.checked ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[var(--ok)]" aria-hidden /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
<span>
|
||||
{i.label}
|
||||
<span className="text-muted-foreground"> · {i.checked ? t("checklist.done") : t("checklist.open")}</span>
|
||||
{i.required ? <span className="text-muted-foreground"> · {t("checklist.required")}</span> : null}
|
||||
{i.comment ? <span className="block text-muted-foreground">{i.comment}</span> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.photos")}</h2>
|
||||
{c.photos.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{t("photos.empty")}</p>
|
||||
) : (
|
||||
<ul className={cn("mt-3 grid gap-3", compact ? "grid-cols-2" : "grid-cols-2 lg:grid-cols-3")}>
|
||||
{c.photos.map((p, idx) => (
|
||||
<li key={p.photoId} className="overflow-hidden rounded-lg border">
|
||||
<a href={fileUrl(p.documentId)} target="_blank" rel="noopener noreferrer">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- authorized API stream, not optimizable */}
|
||||
<img src={fileUrl(p.documentId)} alt={p.comment || t("photos.alt", { index: idx + 1 })} className="aspect-[4/3] w-full bg-muted object-cover" loading="lazy" />
|
||||
</a>
|
||||
<div className="p-2 text-[11.5px]">
|
||||
{p.phase ? <span className="font-semibold">{t(`phase.${p.phase}`)}</span> : null}
|
||||
{p.requirement ? <span className="block text-muted-foreground">{t("photos.requirement", { label: p.requirement })}</span> : null}
|
||||
{p.comment ? <span className="block">{p.comment}</span> : null}
|
||||
<span className="block text-muted-foreground">{dateTime(p.takenAt)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.signature")}</h2>
|
||||
{!c.signature ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{t("signature.none")}</p>
|
||||
) : (
|
||||
<div className="mt-2 text-[13px]">
|
||||
<p className="font-semibold">{t(`outcome.${c.signature.outcome}`)}</p>
|
||||
{c.signature.imageDocumentId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- authorized API stream
|
||||
<img src={fileUrl(c.signature.imageDocumentId)} alt={t("signature.image", { name: c.signature.signerName ?? "" })} className="my-2 max-h-32 rounded-md border bg-white" />
|
||||
) : null}
|
||||
<dl className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
[t("signature.signer"), c.signature.signerName],
|
||||
[t("signature.role"), c.signature.signerRole],
|
||||
[t("signature.signedAt"), dateTime(c.signature.signedAt)],
|
||||
[t("signature.capturedBy"), c.signature.capturedByName],
|
||||
[t("signature.reason"), c.signature.reason],
|
||||
] as Array<[string, string | null]>
|
||||
)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{k}</dt>
|
||||
<dd>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{c.signature.confirmationText ? <p className="mt-2 text-[12px] text-muted-foreground">{c.signature.confirmationText}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckCircle2, CopyPlus, FileDown, UserCheck, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { approveReportAction, newVersionAction, regeneratePdfAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "./action-message";
|
||||
|
||||
/** Backoffice/team lead review actions on /reports/[id]. Visibility is comfort; the services enforce rights. */
|
||||
export function ReviewActions({
|
||||
reportId,
|
||||
can,
|
||||
rejectHref,
|
||||
}: {
|
||||
reportId: string;
|
||||
can: { approve: boolean; approveTeam: boolean; reject: boolean; newVersion: boolean; regeneratePdf: boolean };
|
||||
rejectHref: string;
|
||||
}) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [approveState, approve, approving] = useActionState(approveReportAction, IDLE);
|
||||
const [versionState, newVersion, creating] = useActionState(newVersionAction, IDLE);
|
||||
const [pdfState, regenerate, regenerating] = useActionState(regeneratePdfAction, IDLE);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionState.status === "ok" && versionState.reportId) router.push(`/reports/${versionState.reportId}`);
|
||||
}, [versionState, router]);
|
||||
useEffect(() => {
|
||||
if (approveState.status === "ok" || pdfState.status === "ok") router.refresh();
|
||||
}, [approveState, pdfState, router]);
|
||||
|
||||
if (!Object.values(can).some(Boolean)) return null;
|
||||
const big = "h-11 px-4";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(can.approve || can.approveTeam) && (
|
||||
<form action={approve}>
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<Button type="submit" disabled={approving} className={big}>
|
||||
{can.approve ? <CheckCircle2 aria-hidden /> : <UserCheck aria-hidden />}
|
||||
{can.approve ? t("actions.approve") : t("actions.approveTeam")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{can.reject && (
|
||||
<Button variant="outline" className={big} nativeButton={false} render={<Link href={rejectHref} scroll={false} />}>
|
||||
<XCircle aria-hidden />
|
||||
{t("actions.reject")}
|
||||
</Button>
|
||||
)}
|
||||
{can.newVersion && (
|
||||
<form action={newVersion}>
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<Button type="submit" variant="outline" disabled={creating} className={big} title={t("actions.newVersionHint")}>
|
||||
<CopyPlus aria-hidden />
|
||||
{t("actions.newVersion")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{can.regeneratePdf && (
|
||||
<form action={regenerate}>
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<Button type="submit" variant="outline" disabled={regenerating} className={big}>
|
||||
<FileDown aria-hidden />
|
||||
{t("actions.regeneratePdf")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<ActionMessage state={approveState} />
|
||||
<ActionMessage state={versionState} />
|
||||
<ActionMessage state={pdfState} okText={t("detail.pdfPending")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Eraser } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Signature canvas (Spec §18.1): Pointer Events (mouse, touch, stylus), smoothed strokes, clear, PNG export.
|
||||
* Emits a PNG data URL (max. 1200 px wide) after every stroke, or null when cleared.
|
||||
* Stroke color is read from the CSS token --brand-graphit (no hard-coded colors).
|
||||
*/
|
||||
export function SignaturePad({
|
||||
onChange,
|
||||
labels,
|
||||
disabled = false,
|
||||
}: {
|
||||
onChange: (pngDataUrl: string | null) => void;
|
||||
labels: { clear: string; padLabel: string; padEmpty: string };
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawing = useRef(false);
|
||||
const last = useRef<{ x: number; y: number } | null>(null);
|
||||
const [empty, setEmpty] = useState(true);
|
||||
|
||||
const setup = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--brand-graphit").trim() || "currentColor";
|
||||
setEmpty(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setup();
|
||||
const onResize = () => {
|
||||
setup();
|
||||
onChange(null);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [setup, onChange]);
|
||||
|
||||
const point = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const exportPng = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const maxW = 1200;
|
||||
if (canvas.width <= maxW) return onChange(canvas.toDataURL("image/png"));
|
||||
const scaled = document.createElement("canvas");
|
||||
scaled.width = maxW;
|
||||
scaled.height = Math.round((canvas.height / canvas.width) * maxW);
|
||||
scaled.getContext("2d")?.drawImage(canvas, 0, 0, scaled.width, scaled.height);
|
||||
onChange(scaled.toDataURL("image/png"));
|
||||
};
|
||||
|
||||
const down = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
drawing.current = true;
|
||||
last.current = point(e);
|
||||
const ctx = e.currentTarget.getContext("2d");
|
||||
if (ctx && last.current) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(last.current.x, last.current.y, ctx.lineWidth / 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ctx.strokeStyle;
|
||||
ctx.fill();
|
||||
}
|
||||
setEmpty(false);
|
||||
};
|
||||
|
||||
const move = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawing.current || !last.current) return;
|
||||
const ctx = e.currentTarget.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const events = typeof e.nativeEvent.getCoalescedEvents === "function" ? e.nativeEvent.getCoalescedEvents() : [e.nativeEvent];
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
for (const ev of events) {
|
||||
const p = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
|
||||
const mid = { x: (last.current.x + p.x) / 2, y: (last.current.y + p.y) / 2 };
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(last.current.x, last.current.y);
|
||||
ctx.quadraticCurveTo(last.current.x, last.current.y, mid.x, mid.y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
last.current = p;
|
||||
}
|
||||
};
|
||||
|
||||
const up = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawing.current) return;
|
||||
drawing.current = false;
|
||||
last.current = null;
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
exportPng();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setup();
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative rounded-xl border-2 border-dashed border-input bg-card">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={labels.padLabel}
|
||||
className="block h-52 w-full touch-none select-none sm:h-60"
|
||||
onPointerDown={down}
|
||||
onPointerMove={move}
|
||||
onPointerUp={up}
|
||||
onPointerCancel={up}
|
||||
/>
|
||||
{empty && (
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-6 mx-6 border-t border-input pt-1 text-center text-[13px] text-muted-foreground">
|
||||
{labels.padEmpty}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={clear} disabled={disabled || empty} className="h-12 px-4">
|
||||
<Eraser aria-hidden />
|
||||
{labels.clear}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CheckCircle2, Clock, FilePen, History, UserCheck, XCircle, type LucideIcon } from "lucide-react";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { REPORT_STATUS_TONE, type ReportStatus } from "@/lib/reports/content";
|
||||
|
||||
const ICONS: Record<ReportStatus, LucideIcon> = {
|
||||
draft: FilePen,
|
||||
submitted: Clock,
|
||||
team_approved: UserCheck,
|
||||
approved: CheckCircle2,
|
||||
rejected: XCircle,
|
||||
superseded: History,
|
||||
};
|
||||
|
||||
/** Report status pill: icon + text, never color alone (Brandbook §11.4). */
|
||||
export function ReportStatusBadge({ status, label }: { status: ReportStatus; label: string }) {
|
||||
const Icon = ICONS[status];
|
||||
return (
|
||||
<Pill tone={REPORT_STATUS_TONE[status]}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{label}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Progress for the multi-step mobile completion (Brandbook §12.2). `current` is 1-based. */
|
||||
export function StepIndicator({ steps, current, label }: { steps: string[]; current: number; label: string }) {
|
||||
return (
|
||||
<nav aria-label={label}>
|
||||
<ol className="flex items-center gap-1.5">
|
||||
{steps.map((s, i) => {
|
||||
const n = i + 1;
|
||||
const done = n < current;
|
||||
const active = n === current;
|
||||
return (
|
||||
<li key={s} className="flex min-w-0 flex-1 flex-col items-center gap-1" aria-current={active ? "step" : undefined}>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-7 place-items-center rounded-full border text-[12px] font-bold",
|
||||
done && "border-[var(--ok)] text-[var(--ok)]",
|
||||
active && "border-[var(--ui-accent)] bg-[var(--ui-accent)] text-[var(--ui-accent-foreground)]",
|
||||
!done && !active && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3.5" aria-hidden /> : n}
|
||||
</span>
|
||||
<span className={cn("truncate text-[11px]", active ? "font-semibold" : "text-muted-foreground")}>{s}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="sr-only">{label}</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user