diff --git a/messages/de/reports.json b/messages/de/reports.json
index c97594c..1a7da9f 100644
--- a/messages/de/reports.json
+++ b/messages/de/reports.json
@@ -226,6 +226,37 @@
"alreadySigned": "Unterschrift liegt vor.",
"noReport": "Zuerst den Abschlussbericht erstellen."
},
+ "customerMail": {
+ "title": "An Kunden senden",
+ "sub": "Das freigegebene PDF geht als Anhang an den Kunden. Antworten gehen an die Antwortadresse Ihres Betriebs.",
+ "to": "Empfänger",
+ "toHint": "Vorbelegt mit dem Ansprechpartner des Auftrags bzw. dem Kunden.",
+ "noDefault": "Beim Auftrag ist keine E-Mail-Adresse hinterlegt. Bitte Empfänger eingeben.",
+ "message": "Nachricht (optional)",
+ "messagePlaceholder": "Kurze Nachricht an den Kunden",
+ "submit": "Bericht senden",
+ "sending": "Wird gesendet …",
+ "history": "Bisherige Versände",
+ "historyEmpty": "Noch nicht an den Kunden gesendet.",
+ "result": {
+ "sent": "Bericht an {to} versendet.",
+ "queued": "Bericht an {to} wird versendet.",
+ "duplicate": "Diese Version wurde bereits an {to} gesendet.",
+ "failed": "Versand an {to} fehlgeschlagen. Bitte später erneut versuchen."
+ },
+ "delivery": {
+ "sent": "Versendet",
+ "pending": "In Zustellung",
+ "failed": "Fehlgeschlagen",
+ "other": "Nicht zugestellt"
+ },
+ "errors": {
+ "recipient_missing": "Bitte eine E-Mail-Adresse angeben.",
+ "invalid_to": "Bitte eine gültige E-Mail-Adresse angeben.",
+ "report_not_approved": "Nur freigegebene Berichte können versendet werden.",
+ "pdf_missing": "Das PDF ist noch nicht erzeugt."
+ }
+ },
"pdf": {
"page": "Seite {page} von {pages}",
"reportId": "Bericht-ID",
diff --git a/messages/en/reports.json b/messages/en/reports.json
index 4dbc881..fef17bf 100644
--- a/messages/en/reports.json
+++ b/messages/en/reports.json
@@ -226,6 +226,37 @@
"alreadySigned": "Signature captured.",
"noReport": "Create the completion report first."
},
+ "customerMail": {
+ "title": "Send to customer",
+ "sub": "The approved PDF is sent to the customer as an attachment. Replies go to your company's reply address.",
+ "to": "Recipient",
+ "toHint": "Prefilled with the work order contact or the customer.",
+ "noDefault": "No e-mail address is stored for this work order. Please enter a recipient.",
+ "message": "Message (optional)",
+ "messagePlaceholder": "Short message to the customer",
+ "submit": "Send report",
+ "sending": "Sending …",
+ "history": "Previous sends",
+ "historyEmpty": "Not sent to the customer yet.",
+ "result": {
+ "sent": "Report sent to {to}.",
+ "queued": "Report to {to} is being sent.",
+ "duplicate": "This version has already been sent to {to}.",
+ "failed": "Sending to {to} failed. Please try again later."
+ },
+ "delivery": {
+ "sent": "Sent",
+ "pending": "Being delivered",
+ "failed": "Failed",
+ "other": "Not delivered"
+ },
+ "errors": {
+ "recipient_missing": "Please enter an e-mail address.",
+ "invalid_to": "Please enter a valid e-mail address.",
+ "report_not_approved": "Only approved reports can be sent.",
+ "pdf_missing": "The PDF has not been generated yet."
+ }
+ },
"pdf": {
"page": "Page {page} of {pages}",
"reportId": "Report ID",
diff --git a/src/app/(app)/reports/[id]/page.tsx b/src/app/(app)/reports/[id]/page.tsx
index 53268ce..afc4488 100644
--- a/src/app/(app)/reports/[id]/page.tsx
+++ b/src/app/(app)/reports/[id]/page.tsx
@@ -14,6 +14,8 @@ import { ServiceError } from "@/server/services/context";
import { tenantTimeZone } from "@/server/services/reports/build-content";
import { getReportDetail } from "@/server/services/reports/queries";
import { readCtx } from "@/server/services/reports/read-ctx";
+import { SendToCustomer } from "@/components/reports/send-to-customer";
+import { defaultReportRecipient, listCustomerMailings } from "@/server/services/reports/send-to-customer";
/** /reports/[id] — structured view, PDF, versions, approve/reject/new version (reject as popup ?reject=1). */
export default async function ReportDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reject?: string }> }) {
@@ -29,6 +31,14 @@ export default async function ReportDetailPage({ params, searchParams }: { param
const { report, content, versions, workOrder, permissions } = detail;
const dt = (d: Date | null) => (d ? format.dateTime(d, { dateStyle: "medium", timeStyle: "short", timeZone }) : null);
const base = `/reports/${id}`;
+ // L11: customer mail section — approved report with PDF, backoffice right (the action re-checks against the DB)
+ const customerMail =
+ report.status === "approved" && report.pdfDocumentId && ctx.permissions.has("report:approve")
+ ? await Promise.all([defaultReportRecipient(ctx, report), listCustomerMailings(ctx, id)]).then(([defaultTo, rows]) => ({
+ defaultTo,
+ mailings: rows.map((m) => ({ id: m.id, to: m.to, status: m.status, version: m.version, when: dt(m.sentAt ?? m.createdAt) ?? "" })),
+ }))
+ : null;
const meta: Array<[string, string | null]> = [
[t("field.workOrder"), `${workOrder.number}`],
@@ -87,6 +97,11 @@ export default async function ReportDetailPage({ params, searchParams }: { param
+ {customerMail && (
+
+
+
+ )}
diff --git a/src/app/api/v1/reports/[id]/send/route.ts b/src/app/api/v1/reports/[id]/send/route.ts
new file mode 100644
index 0000000..73a36d0
--- /dev/null
+++ b/src/app/api/v1/reports/[id]/send/route.ts
@@ -0,0 +1,15 @@
+import { requireApiContext } from "@/server/api/context";
+import { json, readJsonObject, withApi } from "@/server/api/respond";
+import { sendReportToCustomer } from "@/server/services/reports/send-to-customer";
+
+/**
+ * POST /api/v1/reports/:id/send — e-mail the approved report PDF to the customer (L11).
+ * Body (optional): { to?: string, message?: string }. 202 queued, 200 sent/duplicate/failed.
+ */
+export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
+ const ctx = await requireApiContext("reports", "report:approve");
+ const { id } = await params;
+ const body = await readJsonObject(req, { allowEmpty: true });
+ const res = await sendReportToCustomer(ctx, { reportId: id, to: body.to, message: body.message });
+ return json(res, { status: res.status === "queued" ? 202 : 200 });
+});
diff --git a/src/components/reports/send-to-customer-state.ts b/src/components/reports/send-to-customer-state.ts
new file mode 100644
index 0000000..271def1
--- /dev/null
+++ b/src/components/reports/send-to-customer-state.ts
@@ -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" };
diff --git a/src/components/reports/send-to-customer.tsx b/src/components/reports/send-to-customer.tsx
new file mode 100644
index 0000000..9223bd8
--- /dev/null
+++ b/src/components/reports/send-to-customer.tsx
@@ -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 = (
+
+ {bad ? : hint ? : }
+ {t(`result.${state.result}`, { to: state.to })}
+
+ );
+ } 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 = (
+
+
+ {key ? t(key) : tr(`errors.${state.code}`)}
+
+ );
+ }
+
+ return (
+
+
+ {t("title")}
+
+ {t("sub")}
+
+
+
+ {t("history")}
+ {mailings.length === 0 ? (
+ {t("historyEmpty")}
+ ) : (
+
+ {mailings.map((m) => (
+ -
+
+ {m.to}
+
+ {" · "}
+ {m.when}
+ {m.version ? ` · v${m.version}` : ""}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+ );
+}
diff --git a/src/lib/api/openapi.ts b/src/lib/api/openapi.ts
index 1730fae..eebd29d 100644
--- a/src/lib/api/openapi.ts
+++ b/src/lib/api/openapi.ts
@@ -1015,6 +1015,33 @@ const paths: Record
> = {
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
}),
},
+ "/reports/{id}/send": {
+ post: op({
+ tag: "Berichte",
+ operationId: "sendReportToCustomer",
+ summary: "Freigegebenes Berichts-PDF per E-Mail an den Kunden senden",
+ description:
+ "Nur Status `approved` mit PDF (sonst 422 `blocked`, details.reason `report_not_approved` | `pdf_missing`). Empfänger: `to` oder Ansprechpartner des Auftrags → Kunde; keiner → 422 `invalid` (details.reason `recipient_missing`). Gleiche Adresse + Version → `duplicate` (keine zweite Mail). Das PDF wird als Dokument-Referenz eingestellt und beim Zustellen mandantengebunden geladen und per SHA-256 geprüft. Audit `export`.",
+ module: "reports",
+ permissions: ["report:approve"],
+ parameters: [idParam("des Berichts")],
+ requestBody: jsonBody(obj({ to: str({ format: "email", maxLength: 254 }), message: str({ maxLength: 2000 }) }), false),
+ responses: {
+ "200": jsonResponse(
+ "Versendet, bereits versendet oder Zustellung fehlgeschlagen",
+ obj(
+ { status: str({ enum: ["sent", "duplicate", "failed"] }), reportId: str(), to: str({ format: "email" }), version: int(), mailLogId: str() },
+ ["status", "reportId", "to", "version"],
+ ),
+ ),
+ "202": jsonResponse(
+ "In die Mail-Queue eingestellt",
+ obj({ status: str({ const: "queued" }), reportId: str(), to: str({ format: "email" }), version: int(), mailLogId: str() }, ["status", "reportId", "to", "version"]),
+ ),
+ ...errors("not_found", "unprocessable"),
+ },
+ }),
+ },
"/sync": {
post: op({
tag: "Einsatz",
diff --git a/src/server/actions/reports/send-to-customer.ts b/src/server/actions/reports/send-to-customer.ts
new file mode 100644
index 0000000..1592ffe
--- /dev/null
+++ b/src/server/actions/reports/send-to-customer.ts
@@ -0,0 +1,25 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import type { SendToCustomerState } from "@/components/reports/send-to-customer-state";
+import { moduleGuard } from "@/server/action-guard";
+import { ctxFromGuard, ServiceError } from "@/server/services/context";
+import { sendReportToCustomer } from "@/server/services/reports/send-to-customer";
+import { errorState, str } from "./_state";
+
+const guard = moduleGuard("reports");
+
+/** Backoffice: send the approved report PDF to the customer by e-mail (form fields: reportId, to, message). */
+export async function sendReportToCustomerAction(_prev: SendToCustomerState, fd: FormData): Promise {
+ try {
+ const ctx = ctxFromGuard(await guard("report:approve"));
+ const res = await sendReportToCustomer(ctx, { reportId: str(fd, "reportId") ?? "", to: str(fd, "to"), message: str(fd, "message") });
+ revalidatePath(`/reports/${res.reportId}`);
+ return { status: "ok", result: res.status, to: res.to, at: Date.now() };
+ } catch (err) {
+ const state = errorState(err);
+ if (state.status !== "error") return { status: "error", code: "generic", at: Date.now() };
+ const reason = err instanceof ServiceError ? (err.details as { reason?: string } | undefined)?.reason : undefined;
+ return { status: "error", code: state.code, field: state.field, reason, at: state.at };
+ }
+}
diff --git a/src/server/services/reports/send-to-customer.ts b/src/server/services/reports/send-to-customer.ts
new file mode 100644
index 0000000..5505da6
--- /dev/null
+++ b/src/server/services/reports/send-to-customer.ts
@@ -0,0 +1,135 @@
+import type { Report } from "@prisma/client";
+import { z } from "zod";
+import { writeAuditLog } from "@/server/audit";
+import { enqueueMail, type EnqueueInput, type EnqueueResult } from "@/server/mail/service";
+import { normalizeLocale, type Locale } from "@/server/mail/templates";
+import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
+import { contentOf, requireVisibleReport } from "./common";
+
+/**
+ * L11 Kundenversand (Spec §17.3 „optional per E-Mail versendet", §36.2):
+ * the approved, immutable report PDF goes to the customer as e-mail attachment.
+ *
+ * - Right `report:approve` (customer communication is a backoffice task), report in scope.
+ * - Only `approved` reports with a PDF; the mail job carries a Document REFERENCE only.
+ * - Recipient: explicit `to` or the work order contact's e-mail, else the customer's e-mail.
+ * - Idempotent per (report version, recipient) via the MailLog dedupeKey → `duplicate`.
+ */
+
+export const CUSTOMER_MAIL_TEMPLATE = "craftvia_report_customer" as const;
+
+const blankToUndefined = (v: unknown) => (typeof v === "string" && v.trim() === "" ? undefined : v);
+
+export const sendReportToCustomerSchema = z.object({
+ reportId: z.string().min(1).max(64),
+ to: z.preprocess((v) => {
+ const b = blankToUndefined(v);
+ return typeof b === "string" ? b.trim().toLowerCase() : b;
+ }, z.email().max(254).optional()),
+ message: z.preprocess(blankToUndefined, z.string().trim().max(2000).optional()),
+});
+export type SendReportToCustomerInput = z.input;
+
+export type SendReportToCustomerResult = {
+ status: "sent" | "queued" | "duplicate" | "failed";
+ reportId: string;
+ to: string;
+ version: number;
+ mailLogId?: string;
+};
+
+export type SendReportDeps = {
+ enqueue: (input: EnqueueInput) => Promise;
+};
+export const defaultSendReportDeps: SendReportDeps = { enqueue: (input) => enqueueMail(input) };
+
+const TYPE_LABEL: Record> = {
+ de: { daily: "Tagesbericht", completion: "Abschlussbericht" },
+ en: { daily: "daily report", completion: "completion report" },
+};
+
+const isEmail = (v: string | null | undefined): v is string => Boolean(v && z.email().max(254).safeParse(v.trim()).success);
+
+export const customerMailDedupePrefix = (reportId: string) => `report-customer:${reportId}:`;
+
+/** Default recipient: contact of the work order (not deleted) → customer. `null` when neither has a valid address. */
+export async function defaultReportRecipient(ctx: ServiceCtx, report: Pick): Promise {
+ const wo = await ctx.db.workOrder.findFirst({
+ where: { id: report.workOrderId },
+ select: { contact: { select: { email: true, deletedAt: true } }, customer: { select: { email: true } } },
+ });
+ const contactEmail = wo?.contact && !wo.contact.deletedAt ? wo.contact.email : null;
+ if (isEmail(contactEmail)) return contactEmail.trim().toLowerCase();
+ if (isEmail(wo?.customer?.email)) return wo.customer.email.trim().toLowerCase();
+ return null;
+}
+
+export async function sendReportToCustomer(
+ ctx: ServiceCtx,
+ raw: SendReportToCustomerInput,
+ deps: SendReportDeps = defaultSendReportDeps,
+): Promise {
+ const input = sendReportToCustomerSchema.parse(raw);
+ assertCan(ctx, "report:approve");
+ const report = await requireVisibleReport(ctx, input.reportId);
+
+ if (report.status !== "approved") throw new ServiceError("blocked", "report_not_approved", { reason: "report_not_approved" });
+ const pdf = report.pdfDocumentId
+ ? await ctx.db.document.findFirst({ where: { id: report.pdfDocumentId, deletedAt: null }, select: { id: true } })
+ : null;
+ if (!pdf) throw new ServiceError("blocked", "pdf_missing", { reason: "pdf_missing" });
+
+ const to = input.to ?? (await defaultReportRecipient(ctx, report));
+ if (!to) throw new ServiceError("invalid", "recipient_missing", { field: "to", reason: "recipient_missing" });
+
+ const content = contentOf(report);
+ const settings = await ctx.db.tenantSettings.findFirst({ select: { locale: true } });
+ const locale = normalizeLocale(settings?.locale);
+ const reportTitle =
+ locale === "en"
+ ? `${TYPE_LABEL.en[report.type]} ${content.reportNumber} for work order ${content.workOrder.number}`
+ : `${TYPE_LABEL.de[report.type]} ${content.reportNumber} zum Auftrag ${content.workOrder.number}`;
+ // reportDate is a DATE column (UTC midnight) → format in UTC like the report page does
+ const reportDate = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { dateStyle: "medium", timeZone: "UTC" }).format(report.reportDate);
+
+ const result = await deps.enqueue({
+ tenantId: ctx.tenantId,
+ template: CUSTOMER_MAIL_TEMPLATE,
+ to,
+ locale,
+ vars: { customerName: content.customer.name, tenantName: content.tenant.name, reportTitle, reportDate, ...(input.message ? { message: input.message } : {}) },
+ attachments: [{ documentId: pdf.id }],
+ dedupeKey: `${customerMailDedupePrefix(report.id)}${to}:${report.version}`,
+ });
+
+ if (result.status === "duplicate") return { status: "duplicate", reportId: report.id, to, version: report.version };
+
+ const status: SendReportToCustomerResult["status"] = result.status === "sent" || result.status === "queued" ? result.status : "failed";
+ await writeAuditLog({
+ tenantId: ctx.tenantId,
+ actorId: ctx.userId,
+ action: "export",
+ entity: "report",
+ entityId: report.id,
+ after: { op: "send_to_customer", to, version: report.version, mailLogId: result.mailLogId, delivery: status, withMessage: Boolean(input.message) },
+ });
+ return { status, reportId: report.id, to, version: report.version, mailLogId: result.mailLogId };
+}
+
+export type CustomerMailing = { id: string; to: string; status: string; version: number | null; createdAt: Date; sentAt: Date | null };
+
+/** Previous customer mailings of this report version (MailLog, template craftvia_report_customer). */
+export async function listCustomerMailings(ctx: ServiceCtx, reportId: string): Promise {
+ const report = await requireVisibleReport(ctx, reportId);
+ const prefix = customerMailDedupePrefix(report.id);
+ const rows = await ctx.db.mailLog.findMany({
+ where: { tenantId: ctx.tenantId, template: CUSTOMER_MAIL_TEMPLATE, dedupeKey: { startsWith: prefix } },
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ select: { id: true, to: true, status: true, dedupeKey: true, createdAt: true, sentAt: true },
+ });
+ return rows.map((r) => {
+ const v = Number((r.dedupeKey ?? "").split(":").pop());
+ return { id: r.id, to: r.to, status: r.status, version: Number.isInteger(v) ? v : null, createdAt: r.createdAt, sentAt: r.sentAt };
+ });
+}