forked from jappel/leistungsbilanz-ts
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
"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<PrintData | null>(null);
|
||
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
|
||
const [office, setOffice] = useState<OfficeProfileDto>({
|
||
officeName: null,
|
||
officeAddress: null,
|
||
});
|
||
const [officeDraft, setOfficeDraft] = useState<{
|
||
officeName: string;
|
||
officeAddress: string;
|
||
} | null>(null);
|
||
const [format, setFormat] = useState<PlanFormat>("a4");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [message, setMessage] = useState<string | null>(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<HTMLInputElement>) {
|
||
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<HTMLFormElement>) {
|
||
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.
|
||
<div
|
||
className={format === "a3" ? "print-view pd-a3" : "print-view"}
|
||
data-bs-theme="light"
|
||
>
|
||
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
|
||
<div className="pd-toolbar">
|
||
<span className="pd-toolbar-title">
|
||
Seitenansicht{data ? ` · ${data.project.name}` : ""}
|
||
</span>
|
||
{message ? <span className="pd-toolbar-message">{message}</span> : null}
|
||
{error ? <span className="pd-toolbar-error">{error}</span> : null}
|
||
<label className="d-flex align-items-center gap-2">
|
||
Planformat
|
||
<select
|
||
className="form-select form-select-sm w-auto"
|
||
onChange={(event) => setFormat(event.target.value as PlanFormat)}
|
||
value={format}
|
||
>
|
||
<option value="a4">A4 quer</option>
|
||
<option value="a3">A3 quer</option>
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="btn btn-sm btn-outline-primary"
|
||
disabled={isBusy}
|
||
onClick={() =>
|
||
setOfficeDraft({
|
||
officeName: office.officeName ?? "",
|
||
officeAddress: office.officeAddress ?? "",
|
||
})
|
||
}
|
||
type="button"
|
||
>
|
||
Büroangaben
|
||
</button>
|
||
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
|
||
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
|
||
<input
|
||
accept={documentLogoMimeTypes.join(",")}
|
||
disabled={isBusy}
|
||
hidden
|
||
onChange={(event) => void handleLogoUpload(event)}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
{logo ? (
|
||
<button
|
||
className="btn btn-sm btn-outline-secondary"
|
||
disabled={isBusy}
|
||
onClick={() => void handleLogoDelete()}
|
||
type="button"
|
||
>
|
||
Logo entfernen
|
||
</button>
|
||
) : null}
|
||
<button
|
||
className="btn btn-sm btn-primary"
|
||
disabled={!data}
|
||
onClick={() => window.print()}
|
||
type="button"
|
||
>
|
||
Als PDF speichern / Drucken
|
||
</button>
|
||
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
|
||
Zum Projekt
|
||
</Link>
|
||
</div>
|
||
{officeDraft ? (
|
||
<FormModal
|
||
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
|
||
isSaving={isBusy}
|
||
onClose={() => setOfficeDraft(null)}
|
||
onSubmit={handleOfficeSave}
|
||
submitLabel="Büroangaben speichern"
|
||
title="Büroangaben"
|
||
>
|
||
<div className="row g-3">
|
||
<div className="col-12">
|
||
<label className="form-label" htmlFor="office-name">
|
||
{officeProfileFieldLabels.officeName}
|
||
</label>
|
||
<input
|
||
autoFocus
|
||
className="form-control"
|
||
id="office-name"
|
||
maxLength={officeProfileFieldMaxLengths.officeName}
|
||
onChange={(event) =>
|
||
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
|
||
}
|
||
value={officeDraft.officeName}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<label className="form-label" htmlFor="office-address">
|
||
{officeProfileFieldLabels.officeAddress}
|
||
</label>
|
||
<textarea
|
||
className="form-control"
|
||
id="office-address"
|
||
maxLength={officeProfileFieldMaxLengths.officeAddress}
|
||
onChange={(event) =>
|
||
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
|
||
}
|
||
placeholder={"Straße Nr.\nPLZ Ort"}
|
||
rows={3}
|
||
value={officeDraft.officeAddress}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</FormModal>
|
||
) : null}
|
||
{data ? (
|
||
<PrintDocument
|
||
entries={entries}
|
||
logoSrc={logoSrc}
|
||
namedSnapshots={data.namedSnapshots}
|
||
office={office}
|
||
printedAt={printedAt}
|
||
project={data.project}
|
||
/>
|
||
) : error ? null : (
|
||
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|