Files
craftvia/src/components/reports/mobile/sign-flow.tsx
T
msolarczekandClaude Opus 5 94092ade3f 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>
2026-09-14 12:22:40 +02:00

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>
);
}