forked from jappel/leistungsbilanz-ts
Integrate print view and document branding
This commit is contained in:
commit
4643c8d11a
59 changed files with 8852 additions and 75 deletions
360
src/app/projects/[projectId]/print/page.tsx
Normal file
360
src/app/projects/[projectId]/print/page.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue