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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user