diff --git a/Dockerfile b/Dockerfile index c4ae678..1c50886 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,3 +103,27 @@ CMD ["node", "server.js"] # GARAGE_RPC_SECRET/GARAGE_ADMIN_TOKEN); nur die secret-freie Basiskonfig wird kopiert. FROM dxflrs/garage:v1.2.0 AS garage COPY deploy/garage.toml /etc/garage.toml + +# --- Worker-Stage (Vorschlag Lane L5 Berichte): Craftvia-Job-Worker inkl. Chromium für PDF --- +# ARCHITEKTUR §1: HTML → PDF läuft über playwright-core + Chromium NUR im Worker, nie im App-Container. +# Debian-Chromium aus dem Paketspiegel statt Playwright-Download (reproduzierbar, Updates über das Base-Image); +# render.ts nutzt PDF_CHROMIUM_PATH. fonts-dejavu/-liberation als Fallback, Inter wird eingebettet (src/app/fonts). +# tsx + src/messages/prisma werden wie in der migrate-Stage zur Laufzeit gebraucht (Worker läuft über tsx). +FROM node:22.14.0-slim AS worker +WORKDIR /app +ENV NODE_ENV=production +ENV PDF_CHROMIUM_PATH=/usr/bin/chromium +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates chromium fonts-dejavu-core fonts-liberation \ + && rm -rf /var/lib/apt/lists/* +COPY --from=deps /app/node_modules ./node_modules +COPY package.json package-lock.json prisma.config.ts tsconfig.json ./ +COPY prisma ./prisma +COPY scripts ./scripts +COPY src ./src +COPY messages ./messages +ENV DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public" +RUN npx prisma generate +RUN groupadd --system --gid 1001 app \ + && useradd --system --uid 1001 --gid app --home-dir /app app +USER app +CMD ["npx", "tsx", "scripts/craftvia-worker.ts"] diff --git a/package-lock.json b/package-lock.json index 98e7df4..62be9a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "isms-tool", + "name": "craftvia", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "isms-tool", + "name": "craftvia", "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", @@ -27,6 +27,7 @@ "next-intl": "^4.13.1", "nodemailer": "^8.0.11", "otplib": "^13.4.1", + "playwright-core": "^1.63.0", "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", @@ -11179,6 +11180,18 @@ "node": ">=4" } }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index 6e47ce7..50fd72b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "next-intl": "^4.13.1", "nodemailer": "^8.0.11", "otplib": "^13.4.1", + "playwright-core": "^1.63.0", "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/src/lib/reports/action-state.ts b/src/lib/reports/action-state.ts new file mode 100644 index 0000000..c86c423 --- /dev/null +++ b/src/lib/reports/action-state.ts @@ -0,0 +1,11 @@ +import type { CompletionBlocker } from "@/lib/work-orders/status"; + +/** Result of report server actions (client-safe; "use server" files may only export async functions). */ +export type ReportActionErrorCode = "generic" | "not_found" | "forbidden" | "conflict" | "invalid" | "blocked"; + +export type ReportActionState = + | { status: "idle" } + | { status: "ok"; reportId?: string; at: number } + | { status: "error"; code: ReportActionErrorCode; field?: string; blockers?: CompletionBlocker[]; at: number }; + +export const IDLE: ReportActionState = { status: "idle" }; diff --git a/src/lib/reports/content.ts b/src/lib/reports/content.ts new file mode 100644 index 0000000..a12ce74 --- /dev/null +++ b/src/lib/reports/content.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +/** + * Report content snapshot (ARCHITEKTUR §4.7, Spec §16.2/§17.2). Client-safe. + * + * Built from the database by src/server/services/reports/build-content.ts when a report is + * created, refreshed on submit/approve and frozen once the report is approved. The editable + * free-text block (`texts`) is owned by the technician and survives every refresh. + */ + +export const REPORT_TYPES = ["daily", "completion"] as const; +export type ReportType = (typeof REPORT_TYPES)[number]; + +export const REPORT_STATUSES = ["draft", "submitted", "team_approved", "approved", "rejected", "superseded"] as const; +export type ReportStatus = (typeof REPORT_STATUSES)[number]; + +/** Statuses in which the technician may still edit texts / capture a signature. */ +export const REPORT_EDITABLE: readonly ReportStatus[] = ["draft", "rejected"]; +/** Statuses waiting for a reviewer ("Zur Prüfung"). */ +export const REPORT_IN_REVIEW: readonly ReportStatus[] = ["submitted", "team_approved"]; + +/** Badge tone per report status — always rendered together with the status text. */ +export const REPORT_STATUS_TONE: Record = { + draft: "mut", + submitted: "info", + team_approved: "info", + approved: "ok", + rejected: "risk", + superseded: "mut", +}; + +export const SIGNATURE_OUTCOMES = ["signed", "customer_absent", "refused", "later", "not_required"] as const; +export type SignatureOutcome = (typeof SIGNATURE_OUTCOMES)[number]; +/** Outcomes that require a written reason (Spec §18.2). */ +export const SIGNATURE_REASON_REQUIRED: readonly SignatureOutcome[] = ["customer_absent", "refused", "later"]; + +export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const; +export const PHOTO_PHASES = ["before", "during", "after"] as const; +export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const; + +/** Editable free-text fields (technician / Lotse draft). */ +export const REPORT_TEXT_FIELDS = ["workPerformed", "deviations", "additionalWork", "problems", "openItems", "nextSteps", "hints"] as const; +export type ReportTextField = (typeof REPORT_TEXT_FIELDS)[number]; +/** Text fields that must not be empty before submit ("Pflichtangaben"). */ +export const REPORT_REQUIRED_TEXTS: Record = { + daily: ["workPerformed"], + completion: ["workPerformed"], +}; + +export const TEXT_MAX = 10_000; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const isoDateTime = z.string().datetime({ offset: true }); +const nullableText = z.string().max(2_000).nullable(); + +export const addressSchema = z.object({ + line1: nullableText, + line2: nullableText, +}); + +export const reportTextsSchema = z.object({ + workPerformed: z.string().max(TEXT_MAX), + deviations: z.string().max(TEXT_MAX), + additionalWork: z.string().max(TEXT_MAX), + problems: z.string().max(TEXT_MAX), + openItems: z.string().max(TEXT_MAX), + nextSteps: z.string().max(TEXT_MAX), + hints: z.string().max(TEXT_MAX), +}); +export type ReportTexts = z.infer; + +export const materialLineSchema = z.object({ + usageId: z.string().nullable(), + planId: z.string().nullable(), + name: z.string(), + articleNumber: nullableText, + plannedQuantity: z.string().nullable(), + actualQuantity: z.string().nullable(), + unit: z.string(), + status: z.enum(MATERIAL_USAGE_STATUSES).nullable(), + /** planned vs. actual differs (quantity or status) */ + deviation: z.boolean(), + deviationReason: nullableText, + notes: nullableText, + /** false = planned material without any documented usage */ + documented: z.boolean(), +}); +export type MaterialLine = z.infer; + +export const photoLineSchema = z.object({ + photoId: z.string(), + documentId: z.string(), + phase: z.enum(PHOTO_PHASES).nullable(), + comment: nullableText, + requirement: nullableText, + takenAt: isoDateTime, +}); +export type PhotoLine = z.infer; + +export const timeLineSchema = z.object({ + userId: z.string(), + name: z.string(), + type: z.enum(TIME_ENTRY_TYPES), + minutes: z.number().int().nonnegative(), +}); + +export const signatureBlockSchema = z.object({ + outcome: z.enum(SIGNATURE_OUTCOMES), + signerName: nullableText, + signerRole: nullableText, + signedAt: isoDateTime, + reason: nullableText, + confirmationText: nullableText, + imageDocumentId: z.string().nullable(), + capturedByName: nullableText, +}); +export type SignatureBlock = z.infer; + +export const reportContentSchema = z.object({ + schemaVersion: z.literal(1), + type: z.enum(REPORT_TYPES), + reportNumber: z.string(), + version: z.number().int().positive(), + reportDate: isoDate, + generatedAt: isoDateTime, + tenant: z.object({ + name: z.string(), + address: nullableText, + phone: nullableText, + email: nullableText, + logoDocumentId: z.string().nullable(), + }), + customer: z.object({ + id: z.string(), + number: nullableText, + name: z.string(), + address: addressSchema, + }), + site: z.object({ id: z.string(), name: z.string(), address: addressSchema }).nullable(), + contact: z.object({ name: z.string(), role: nullableText, phone: nullableText, email: nullableText }).nullable(), + workOrder: z.object({ + id: z.string(), + number: z.string(), + externalOrderNumber: nullableText, + title: z.string(), + description: z.string().nullable(), + scope: z.string().nullable(), + orderType: nullableText, + signatureRequired: z.boolean(), + }), + /** Dates (YYYY-MM-DD, tenant time zone) with documented work; daily report = [reportDate]. */ + workDates: z.array(isoDate), + staff: z.array(z.object({ userId: z.string(), name: z.string() })), + time: z.object({ + entries: z.array(timeLineSchema), + totalsByType: z.record(z.string(), z.number().int().nonnegative()), + totalsByPerson: z.array(z.object({ userId: z.string(), name: z.string(), minutes: z.number().int().nonnegative() })), + /** billable total = all types except break */ + totalMinutes: z.number().int().nonnegative(), + /** true if an entry was still running while the snapshot was built */ + hasRunningEntries: z.boolean(), + }), + texts: reportTextsSchema, + materials: z.object({ + used: z.array(materialLineSchema), + notUsed: z.array(materialLineSchema), + additional: z.array(materialLineSchema), + }), + photos: z.array(photoLineSchema), + checklist: z.array(z.object({ label: z.string(), required: z.boolean(), checked: z.boolean(), comment: nullableText })), + signature: signatureBlockSchema.nullable(), + technician: z.object({ userId: z.string(), name: z.string() }).nullable(), +}); + +export type ReportContent = z.infer; + +export function emptyTexts(): ReportTexts { + return { workPerformed: "", deviations: "", additionalWork: "", problems: "", openItems: "", nextSteps: "", hints: "" }; +} + +/** Parse stored JSON; throws on schema drift so broken snapshots never render silently. */ +export function parseReportContent(json: unknown): ReportContent { + return reportContentSchema.parse(json); +} + +/** Missing required text fields for submit. */ +export function missingRequiredTexts(content: Pick): ReportTextField[] { + return REPORT_REQUIRED_TEXTS[content.type].filter((f) => !content.texts[f].trim()); +} + +/** "7 h 05 min" style duration without locale dependency (labels come from messages). */ +export function splitMinutes(minutes: number): { hours: number; minutes: number } { + return { hours: Math.floor(minutes / 60), minutes: minutes % 60 }; +} diff --git a/src/lib/reports/dates.ts b/src/lib/reports/dates.ts new file mode 100644 index 0000000..033001f --- /dev/null +++ b/src/lib/reports/dates.ts @@ -0,0 +1,46 @@ +/** + * Calendar-day helpers in the tenant time zone (client-safe, no dependencies). + * A daily report covers [start of reportDate, start of next day) in `timeZone`. + */ + +/** Offset (ms) of `timeZone` relative to UTC at the given instant. */ +function tzOffsetMs(instant: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(instant); + const get = (t: string) => Number(parts.find((p) => p.type === t)?.value); + const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")); + return asUtc - Math.floor(instant.getTime() / 1000) * 1000; +} + +/** YYYY-MM-DD of an instant in `timeZone`. */ +export function localDateKey(instant: Date, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant); +} + +/** UTC instants [start, end) of a local calendar day. */ +export function dayWindow(dateKey: string, timeZone: string): { start: Date; end: Date } { + const [y, m, d] = dateKey.split("-").map(Number); + const startGuess = Date.UTC(y, m - 1, d); + const endGuess = Date.UTC(y, m - 1, d + 1); + const start = new Date(startGuess - tzOffsetMs(new Date(startGuess), timeZone)); + const end = new Date(endGuess - tzOffsetMs(new Date(endGuess), timeZone)); + return { start, end }; +} + +/** Date-only column value (Prisma @db.Date) for a YYYY-MM-DD key. */ +export function dateKeyToDbDate(dateKey: string): Date { + return new Date(`${dateKey}T00:00:00.000Z`); +} + +/** YYYY-MM-DD from a Prisma @db.Date value. */ +export function dbDateToKey(d: Date): string { + return d.toISOString().slice(0, 10); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index 8727bd4..20fe017 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -10,7 +10,7 @@ export type JobProcessor = (payload: JobPayload) => Promise; export const PROCESSORS: Partial Promise>> = { // lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process), // lane-lotse: "transcription": () => import("./transcription").then((m) => m.process), - // lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process), + "report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle // lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process), }; diff --git a/src/server/jobs/processors/report-pdf.ts b/src/server/jobs/processors/report-pdf.ts new file mode 100644 index 0000000..c21f140 --- /dev/null +++ b/src/server/jobs/processors/report-pdf.ts @@ -0,0 +1,19 @@ +import { dbForTenant } from "@/server/db"; +import type { ServiceCtx } from "@/server/services/context"; +import { generateReportPdf } from "@/server/services/reports/pdf"; +import type { JobPayload } from "../queues"; + +/** + * Queue "report-pdf": renders the PDF of an approved report (lane L5). + * System context: tenant-bound db, read access to all reports of the tenant — nothing else. + */ +export async function process(payload: JobPayload): Promise { + const ctx: ServiceCtx = { + db: dbForTenant(payload.tenantId), + tenantId: payload.tenantId, + userId: payload.actorId ?? "system", + permissions: new Set(["report:read", "work_order:read_all", "document:read_internal"]), + }; + const res = await generateReportPdf(ctx, payload.entityId); + console.info(`[report-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`); +} diff --git a/src/server/pdf/render.ts b/src/server/pdf/render.ts new file mode 100644 index 0000000..547f421 --- /dev/null +++ b/src/server/pdf/render.ts @@ -0,0 +1,85 @@ +import { existsSync } from "node:fs"; + +/** + * HTML → PDF via playwright-core + Chromium (ARCHITEKTUR §1: runs in the worker, never in the app container). + * + * Browser resolution (first match wins): + * 1. PDF_CHROMIUM_PATH — explicit executable (Docker worker image: /usr/bin/chromium) + * 2. Playwright-managed Chromium (`npx playwright-core install chromium`) + * 3. Locally installed Google Chrome (developer machines, channel "chrome") + * Throws PdfRendererUnavailableError if none can be launched. + */ + +export class PdfRendererUnavailableError extends Error { + constructor(cause: string) { + super(`PDF renderer unavailable: ${cause}`); + this.name = "PdfRendererUnavailableError"; + } +} + +export type RenderPdfOptions = { + headerHtml?: string; + footerHtml?: string; + /** mm margins */ + margin?: { top: string; bottom: string; left: string; right: string }; +}; + +type Browser = import("playwright-core").Browser; + +async function launch(): Promise { + const { chromium } = await import("playwright-core"); + const errors: string[] = []; + const explicit = process.env.PDF_CHROMIUM_PATH?.trim(); + const attempts: Array<() => Promise> = []; + if (explicit) attempts.push(() => chromium.launch({ executablePath: explicit, args: ["--no-sandbox", "--disable-dev-shm-usage"] })); + attempts.push(async () => { + const path = chromium.executablePath(); + if (!path || !existsSync(path)) throw new Error("playwright chromium not installed"); + return chromium.launch({ args: ["--disable-dev-shm-usage"] }); + }); + attempts.push(() => chromium.launch({ channel: "chrome" })); + for (const attempt of attempts) { + try { + return await attempt(); + } catch (err) { + errors.push((err as Error).message.split("\n")[0]); + } + } + throw new PdfRendererUnavailableError(errors.join(" | ")); +} + +/** Render a full HTML document to an A4 PDF (print backgrounds, header/footer with page numbers). */ +export async function renderHtmlToPdf(html: string, opts: RenderPdfOptions = {}): Promise { + const browser = await launch(); + try { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + // No network: all assets (photos, logo, signature, fonts) are inlined as data: URIs. + await page.route("**/*", (route) => (route.request().url().startsWith("data:") ? route.continue() : route.abort())); + await page.setContent(html, { waitUntil: "load" }); + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + displayHeaderFooter: Boolean(opts.headerHtml || opts.footerHtml), + headerTemplate: opts.headerHtml ?? "", + footerTemplate: opts.footerHtml ?? "", + margin: opts.margin ?? { top: "22mm", bottom: "20mm", left: "16mm", right: "16mm" }, + preferCSSPageSize: false, + }); + await context.close(); + return pdf; + } finally { + await browser.close(); + } +} + +/** true if a browser can be launched (tests skip the render smoke otherwise). */ +export async function pdfRendererAvailable(): Promise<{ ok: true } | { ok: false; reason: string }> { + try { + const b = await launch(); + await b.close(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: (err as Error).message }; + } +} diff --git a/src/server/pdf/templates/report.tsx b/src/server/pdf/templates/report.tsx new file mode 100644 index 0000000..ce87ad2 --- /dev/null +++ b/src/server/pdf/templates/report.tsx @@ -0,0 +1,322 @@ +/* eslint-disable @next/next/no-head-element, @next/next/no-img-element -- standalone print document for Chromium, not a Next.js page */ +import { renderToStaticMarkup } from "react-dom/server"; +import { REPORT_TEXT_FIELDS, splitMinutes, type MaterialLine, type ReportContent } from "@/lib/reports/content"; +import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand"; + +/** + * Report PDF template (React SSR → static HTML, rendered by src/server/pdf/render.ts). + * Craftvia document CD from src/lib/document-brand.ts; tenant logo if available, else company name. + * All labels come from messages//reports.json (passed in as `t`). + */ + +export type Translate = (key: string, values?: Record) => string; + +export type ReportPdfInput = { + content: ReportContent; + reportId: string; + status: string; + approvedAt: Date | null; + t: Translate; + locale: string; + timeZone: string; + /** documentId → data: URI (photos, signature image, logo) */ + images: Record; + logoDataUri?: string | null; + /** SHA-256 of the canonical content snapshot (the PDF's own checksum is stored on the report) */ + contentChecksum: string; + fontDataUri?: string | null; +}; + +const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +function css(fontDataUri?: string | null) { + const th = DOCUMENT_THEME; + return ` +${fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${fontDataUri}) format("truetype");font-weight:100 900;}` : ""} +@page{size:A4;} +*{box-sizing:border-box;} +html,body{margin:0;padding:0;} +body{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.bodyFont};color:${th.text};font-size:9.5pt;line-height:1.45;background:${th.pageBackground};} +h1{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:17pt;margin:0 0 2mm;} +h2{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:11pt;margin:6mm 0 2mm;padding-bottom:1mm;border-bottom:0.6pt solid ${th.rule};break-after:avoid;} +h3{font-size:9.5pt;margin:3mm 0 1mm;color:${th.text};break-after:avoid;} +.muted{color:${th.textMuted};} +.head{display:flex;justify-content:space-between;align-items:flex-start;gap:8mm;border-bottom:2pt solid ${th.accentStrong};padding-bottom:3mm;margin-bottom:4mm;} +.logo{max-height:16mm;max-width:60mm;} +.org{font-weight:700;color:${th.accent};font-size:12pt;} +.meta{text-align:right;font-size:8.5pt;} +.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5mm 8mm;} +.kv dt{font-size:7.5pt;text-transform:uppercase;letter-spacing:.04em;color:${th.textMuted};margin:0;} +.kv dd{margin:0 0 1.5mm;} +table{width:100%;border-collapse:collapse;margin:1mm 0 2mm;} +th{background:${th.tableHeaderBackground};text-align:left;font-size:8pt;padding:1.2mm 1.5mm;border-bottom:0.6pt solid ${th.rule};} +td{padding:1.2mm 1.5mm;border-bottom:0.4pt solid ${th.rule};vertical-align:top;} +tr{break-inside:avoid;} +td.num,th.num{text-align:right;white-space:nowrap;} +.text{white-space:pre-wrap;break-inside:avoid-page;} +.photos{display:grid;grid-template-columns:1fr 1fr;gap:4mm;} +.photo{break-inside:avoid;border:0.4pt solid ${th.rule};padding:1.5mm;} +.photo img{width:100%;height:62mm;object-fit:contain;background:${th.tableHeaderBackground};display:block;} +.photo .cap{font-size:8pt;margin-top:1mm;} +.sig{break-inside:avoid;border:0.6pt solid ${th.rule};padding:3mm;} +.sig img{max-height:30mm;max-width:90mm;display:block;margin:2mm 0;} +.badge{display:inline-block;border:0.6pt solid ${th.accent};color:${th.accent};border-radius:2mm;padding:.3mm 2mm;font-size:8pt;font-weight:700;} +.dev{color:${th.accentStrong};font-weight:700;} +`; +} + +function Kv({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function fmtDuration(t: Translate, minutes: number) { + const s = splitMinutes(minutes); + return t("time.hoursMinutes", { hours: s.hours, minutes: String(s.minutes).padStart(2, "0") }); +} + +function MaterialTable({ t, lines }: { t: Translate; lines: MaterialLine[] }) { + return ( + + + + + + + + + + + + {lines.map((m, i) => ( + + + + + + + + ))} + +
{t("materials.name")}{t("materials.planned")}{t("materials.actual")}{t("field.status")}{t("materials.reason")}
+ {m.name} + {m.articleNumber ? · {m.articleNumber} : null} + {m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}{m.actualQuantity ? `${m.actualQuantity} ${m.unit}` : "—"} + {m.status ? t(`materialStatus.${m.status}`) : t("materials.undocumented")} + {m.deviation ? · {t("materials.deviation")} : null} + {m.deviationReason ?? ""}
+ ); +} + +function ReportDocument(input: ReportPdfInput) { + const { content: c, t } = input; + const dateFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium" }); + const dateTimeFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium", timeStyle: "short" }); + const dayFmt = (key: string) => new Intl.DateTimeFormat(input.locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`)); + const addr = (a: { line1: string | null; line2: string | null }) => [a.line1, a.line2].filter(Boolean).join(", "); + const texts = REPORT_TEXT_FIELDS.filter((f) => c.texts[f].trim()); + const hasMaterial = c.materials.used.length + c.materials.notUsed.length + c.materials.additional.length > 0; + + return ( + + + + {`${t(`type.${c.type}`)} ${c.reportNumber}`} +