/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>
137 lines
6.0 KiB
TypeScript
137 lines
6.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|