L11 Kundenversand: Bericht-PDF an Kunden senden (Service, Action, API, Berichtsseite)
- sendReportToCustomer: report:approve, Scope, nur approved mit PDF, Empfänger
Ansprechpartner -> Kunde, Dedupe je Adresse+Version, Audit export
- Server Action + POST /api/v1/reports/{id}/send inkl. OpenAPI-Eintrag
- /reports/[id]: Abschnitt „An Kunden senden" mit bisherigen Versänden, Texte de/en
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import type { ReportActionErrorCode } from "@/lib/reports/action-state";
|
||||
|
||||
/** Result of the "send report to customer" action (client-safe; kept out of the "use server" file). */
|
||||
export type SendToCustomerState =
|
||||
| { status: "idle" }
|
||||
| { status: "ok"; result: "sent" | "queued" | "duplicate" | "failed"; to: string; at: number }
|
||||
| { status: "error"; code: ReportActionErrorCode; field?: string; reason?: string; at: number };
|
||||
|
||||
export const SEND_TO_CUSTOMER_IDLE: SendToCustomerState = { status: "idle" };
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckCircle2, Clock, Info, Mail, XCircle } 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 { sendReportToCustomerAction } from "@/server/actions/reports/send-to-customer";
|
||||
import { SEND_TO_CUSTOMER_IDLE } from "./send-to-customer-state";
|
||||
|
||||
export type CustomerMailingRow = { id: string; to: string; status: string; version: number | null; when: string };
|
||||
|
||||
const KNOWN_REASONS = new Set(["recipient_missing", "report_not_approved", "pdf_missing"]);
|
||||
|
||||
/** /reports/[id] section „An Kunden senden" (approved + report:approve). The service enforces rights and status. */
|
||||
export function SendToCustomer({ reportId, defaultTo, mailings }: { reportId: string; defaultTo: string | null; mailings: CustomerMailingRow[] }) {
|
||||
const t = useTranslations("reports.customerMail");
|
||||
const tr = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(sendReportToCustomerAction, SEND_TO_CUSTOMER_IDLE);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") router.refresh();
|
||||
}, [state, router]);
|
||||
|
||||
let feedback: React.ReactNode = null;
|
||||
if (state.status === "ok") {
|
||||
const bad = state.result === "failed";
|
||||
const hint = state.result === "duplicate";
|
||||
feedback = (
|
||||
<p role={bad ? "alert" : "status"} className={`flex items-center gap-2 text-[13px] font-semibold ${bad ? "text-[var(--risk)]" : hint ? "text-muted-foreground" : "text-[var(--ok)]"}`}>
|
||||
{bad ? <XCircle className="size-4 shrink-0" aria-hidden /> : hint ? <Info className="size-4 shrink-0" aria-hidden /> : <CheckCircle2 className="size-4 shrink-0" aria-hidden />}
|
||||
{t(`result.${state.result}`, { to: state.to })}
|
||||
</p>
|
||||
);
|
||||
} else if (state.status === "error") {
|
||||
const key =
|
||||
state.reason && KNOWN_REASONS.has(state.reason)
|
||||
? `errors.${state.reason}`
|
||||
: state.field === "to"
|
||||
? "errors.invalid_to"
|
||||
: null;
|
||||
feedback = (
|
||||
<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 />
|
||||
{key ? t(key) : tr(`errors.${state.code}`)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="shadow-card rounded-xl border bg-card p-4" aria-labelledby="customer-mail-title">
|
||||
<h2 id="customer-mail-title" className="font-heading text-[15px] font-semibold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("sub")}</p>
|
||||
|
||||
<form action={action} className="mt-3 space-y-3">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<div>
|
||||
<Label htmlFor="customer-mail-to">{t("to")} *</Label>
|
||||
<Input
|
||||
id="customer-mail-to"
|
||||
name="to"
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
autoComplete="email"
|
||||
defaultValue={defaultTo ?? ""}
|
||||
className="mt-1 h-11"
|
||||
aria-invalid={state.status === "error" && state.field === "to"}
|
||||
aria-describedby="customer-mail-to-hint"
|
||||
/>
|
||||
<p id="customer-mail-to-hint" className="mt-1 text-[12px] text-muted-foreground">
|
||||
{defaultTo ? t("toHint") : t("noDefault")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="customer-mail-message">{t("message")}</Label>
|
||||
<Textarea id="customer-mail-message" name="message" maxLength={2000} rows={3} className="mt-1 min-h-20" placeholder={t("messagePlaceholder")} />
|
||||
</div>
|
||||
{feedback}
|
||||
<Button type="submit" disabled={pending} className="h-11 px-4">
|
||||
<Mail aria-hidden />
|
||||
{pending ? t("sending") : t("submit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h3 className="mt-4 text-[13px] font-semibold">{t("history")}</h3>
|
||||
{mailings.length === 0 ? (
|
||||
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("historyEmpty")}</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{mailings.map((m) => (
|
||||
<li key={m.id} className="flex flex-wrap items-center justify-between gap-2 text-[13px]">
|
||||
<span className="min-w-0 break-all">
|
||||
{m.to}
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{m.when}
|
||||
{m.version ? ` · v${m.version}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<DeliveryStatus status={m.status} label={t(`delivery.${m.status === "sent" || m.status === "pending" || m.status === "failed" ? m.status : "other"}`)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryStatus({ status, label }: { status: string; label: string }) {
|
||||
const Icon = status === "sent" ? CheckCircle2 : status === "pending" ? Clock : XCircle;
|
||||
const tone = status === "sent" ? "text-[var(--ok)]" : status === "pending" ? "text-muted-foreground" : "text-[var(--risk)]";
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[12.5px] font-semibold ${tone}`}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user