"use client"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react"; import "../../../../frontend/print/print-document.css"; import { deleteDocumentLogo, documentLogoUrl, getCircuitTree, getDocumentBranding, getProject, listCircuitLists, listDistributionBoards, listFloors, listProjectSnapshots, updateOfficeProfile, uploadDocumentLogo, } from "../../../../frontend/utils/api"; import type { DocumentLogoMetadataDto, OfficeProfileDto, ProjectDto, ProjectSnapshotMetadataDto, } from "../../../../frontend/types"; import { FormModal } from "../../../../frontend/components/form-modal"; import { officeProfileFieldLabels, officeProfileFieldMaxLengths, } from "../../../../shared/constants/project-cover"; import { PrintDocument } from "../../../../frontend/print/print-document"; import { assertPrintRevisionConsistency, buildPreliminaryBoardHierarchy, summarizePrintBoard, type PrintBoardSummary, } from "../../../../frontend/print/print-document-model"; import { documentLogoMimeTypes, maxDocumentLogoBytes, } from "../../../../shared/constants/document-branding"; type PlanFormat = "a4" | "a3"; interface PrintData { project: ProjectDto; summaries: PrintBoardSummary[]; namedSnapshots: ProjectSnapshotMetadataDto[]; } const boardSortCollator = new Intl.Collator("de", { numeric: true }); function cssString(value: string) { return JSON.stringify(value.replace(/[<>\r\n]/g, " ")); } function pageRules(project: ProjectDto, stand: string, format: PlanFormat) { const marginBoxes = ` @bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } @bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }`; return ` @page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} } @page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`; } export default function ProjectPrintPage() { const params = useParams<{ projectId: string }>(); const projectId = params.projectId; const [data, setData] = useState(null); const [logo, setLogo] = useState(null); const [office, setOffice] = useState({ officeName: null, officeAddress: null, }); const [officeDraft, setOfficeDraft] = useState<{ officeName: string; officeAddress: string; } | null>(null); const [format, setFormat] = useState("a4"); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [isBusy, setIsBusy] = useState(false); const [printedAt] = useState(() => new Date()); useEffect(() => { if (!projectId) { return; } let cancelled = false; async function load() { const [project, boards, circuitLists, floors, snapshots, branding] = await Promise.all([ getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), listFloors(projectId), listProjectSnapshots(projectId), getDocumentBranding(), ]); const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name])); const summaries = await Promise.all( [...boards] .sort( (left, right) => boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") || boardSortCollator.compare(left.name, right.name) ) .flatMap((board) => { const circuitList = circuitLists.find( (list) => list.distributionBoardId === board.id ); return circuitList ? [{ board, circuitList }] : []; }) .map(async ({ board, circuitList }) => summarizePrintBoard({ board, circuitListId: circuitList.id, tree: await getCircuitTree(projectId, circuitList.id), floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null, }) ) ); const finalProject = await getProject(projectId); assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision); if (cancelled) { return; } setData({ project, summaries, namedSnapshots: snapshots .filter((snapshot) => snapshot.kind === "named") .sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)), }); setLogo(branding.logo); setOffice(branding.office); document.title = `Leistungsbilanz – ${project.name}`; } load().catch((err: unknown) => { if (!cancelled) { setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden."); } }); return () => { cancelled = true; }; }, [projectId]); const entries = useMemo( () => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []), [data] ); const stand = useMemo( () => new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", }).format(printedAt), [printedAt] ); const logoSrc = logo ? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}` : null; async function handleLogoUpload(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) { return; } if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) { setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen."); return; } if (file.size > maxDocumentLogoBytes) { setError("Das Logo darf höchstens 2 MB groß sein."); return; } setIsBusy(true); setError(null); try { const result = await uploadDocumentLogo(file); setLogo(result.logo); setMessage("Logo hinterlegt. Es gilt für alle Projekte."); } catch (err) { setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden."); } finally { setIsBusy(false); } } async function handleOfficeSave(event: FormEvent) { event.preventDefault(); if (!officeDraft) { return; } setIsBusy(true); setError(null); try { const result = await updateOfficeProfile({ officeName: officeDraft.officeName.trim() || null, officeAddress: officeDraft.officeAddress.trim() || null, }); setOffice(result.office); setOfficeDraft(null); setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte."); } catch (err) { setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden."); } finally { setIsBusy(false); } } async function handleLogoDelete() { if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) { return; } setIsBusy(true); setError(null); try { await deleteDocumentLogo(); setLogo(null); setMessage("Logo entfernt."); } catch (err) { setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden."); } finally { setIsBusy(false); } } return ( // Paper output: always light, independent of the app's dark mode.
{data ? : null}
Seitenansicht{data ? ` · ${data.project.name}` : ""} {message ? {message} : null} {error ? {error} : null} {logo ? ( ) : null} Zum Projekt
{officeDraft ? ( setOfficeDraft(null)} onSubmit={handleOfficeSave} submitLabel="Büroangaben speichern" title="Büroangaben" >
setOfficeDraft({ ...officeDraft, officeName: event.target.value }) } value={officeDraft.officeName} />