L5 Berichte & Unterschrift: Backoffice- und Mobil-Oberflächen

/reports mit Filtern (zur Prüfung zuerst), /reports/[id] mit Aktionen, Versionen und PDF-Link;
mobile Komponenten für Bericht, Prüfung und Unterschrift inkl. Signature-Pad; Texte de/en.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent 8f53df6208
commit 94092ade3f
20 changed files with 1805 additions and 3 deletions
+35
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+136
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}