Integrate print view and document branding

This commit is contained in:
Julian Appel 2026-09-27 10:32:07 +02:00
commit 4643c8d11a
59 changed files with 8852 additions and 75 deletions

View file

@ -0,0 +1,110 @@
"use client";
import {
distributionBoardInfoFieldLabels,
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board";
import type { DistributionBoardInfoDto } from "../types";
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
const placeholders: Record<DistributionBoardInfoField, string> = {
boardNumber: "z. B. UV 1.1",
location: "z. B. Raum 003 Technik",
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
remark: "",
};
export function toDistributionBoardInfoDraft(
info: DistributionBoardInfoDto
): DistributionBoardInfoDraft {
return {
boardNumber: info.boardNumber ?? "",
location: info.location ?? "",
fedFrom: info.fedFrom ?? "",
remark: info.remark ?? "",
};
}
export function fromDistributionBoardInfoDraft(
draft: DistributionBoardInfoDraft
): DistributionBoardInfoDto {
const value = (field: DistributionBoardInfoField) =>
draft[field].trim() ? draft[field].trim() : null;
return {
boardNumber: value("boardNumber"),
location: value("location"),
fedFrom: value("fedFrom"),
remark: value("remark"),
};
}
/**
* Descriptive board data used by the print view title block. The values are
* local planning values; a later Revit import may prefill them only through
* an explicit, previewed decision.
*/
export function DistributionBoardInfoCard({
draft,
onChange,
}: {
draft: DistributionBoardInfoDraft;
onChange: (draft: DistributionBoardInfoDraft) => void;
}) {
return (
<section
aria-labelledby="distribution-board-info-heading"
className="card"
>
<div className="card-header" id="distribution-board-info-heading">
Infokarte
</div>
<div className="card-body row g-3">
{distributionBoardInfoFields.map((field) => (
<div
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label
className="form-label"
htmlFor={`distribution-board-info-${field}`}
>
{distributionBoardInfoFieldLabels[field]}
</label>
{field === "remark" ? (
<textarea
className="form-control"
id={`distribution-board-info-${field}`}
maxLength={distributionBoardInfoFieldMaxLengths[field]}
onChange={(event) =>
onChange({ ...draft, [field]: event.target.value })
}
rows={2}
value={draft[field]}
/>
) : (
<input
className="form-control"
id={`distribution-board-info-${field}`}
maxLength={distributionBoardInfoFieldMaxLengths[field]}
onChange={(event) =>
onChange({ ...draft, [field]: event.target.value })
}
placeholder={placeholders[field]}
value={draft[field]}
/>
)}
{field === "fedFrom" ? (
<div className="form-text">
Vorerst Freitext. Die logische Verknüpfung mit dem
speisenden Verteiler folgt.
</div>
) : null}
</div>
))}
</div>
</section>
);
}

View file

@ -7,9 +7,16 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
import {
projectCoverFieldLabels,
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput {
export interface ProjectSettingsInput
extends Record<ProjectCoverField, string | null> {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
siteAddress: "Straße Nr.\nPLZ Ort",
workStage: "z. B. LPH 5 – Ausführungsplanung",
projectLead: "",
projectEditor: "",
};
interface ProjectSettingsModalProps {
isSaving: boolean;
onClose: () => void;
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes
);
const [coverValues, setCoverValues] = useState<
Record<ProjectCoverField, string>
>({
siteAddress: project.siteAddress ?? "",
workStage: project.workStage ?? "",
projectLead: project.projectLead ?? "",
projectEditor: project.projectEditor ?? "",
});
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes,
siteAddress: toNullableString(coverValues.siteAddress),
workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor),
});
}
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Deckblatt</h3>
<p className="text-secondary small">
Angaben für das Deckblatt der Seitenansicht. Büroname und
Büroanschrift werden projektübergreifend in der Seitenansicht
gepflegt.
</p>
<div className="row g-3">
{projectCoverFields.map((field) => (
<div
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label className="form-label" htmlFor={`project-cover-${field}`}>
{projectCoverFieldLabels[field]}
</label>
{field === "siteAddress" ? (
<textarea
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
rows={2}
value={coverValues[field]}
/>
) : (
<input
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
value={coverValues[field]}
/>
)}
</div>
))}
</div>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>

View file

@ -0,0 +1,191 @@
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
import type {
CircuitTreeCircuitDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../types";
/**
* Pure read model for the print view (cover page, board overview and board
* plans). It only derives display values from the existing circuit-tree
* responses and never changes project data.
*/
export interface PrintBoardSummary {
board: DistributionBoardDto;
circuitListId: string;
tree: CircuitTreeResponseDto;
floorName: string | null;
circuitCount: number;
/** Σ quantity × power per unit, without any simultaneity factor. */
installedPowerKw: number;
/** Σ row totals, i.e. with the device-row simultaneity factors. */
devicePowerKw: number;
simultaneityFactor: number;
/** Device power with the board simultaneity factor applied. */
demandPowerKw: number;
/**
* Symmetrical three-phase design current from the apparent power
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
*/
designCurrentA: number;
}
export function summarizePrintBoard(input: {
board: DistributionBoardDto;
circuitListId: string;
tree: CircuitTreeResponseDto;
floorName: string | null;
}): PrintBoardSummary {
const { tree } = input;
let circuitCount = 0;
let installedPowerKw = 0;
let apparentPowerKva = 0;
for (const section of tree.sections) {
for (const circuit of section.circuits) {
circuitCount += 1;
for (const row of circuit.deviceRows) {
installedPowerKw += row.quantity * row.powerPerUnit;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
}
}
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
return {
...input,
circuitCount,
installedPowerKw,
devicePowerKw: tree.distributionBoardTotalPower,
simultaneityFactor,
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
designCurrentA:
(apparentPowerKva * simultaneityFactor * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV),
};
}
export interface PrintBoardHierarchyEntry {
summary: PrintBoardSummary;
depth: number;
parentBoardId: string | null;
/** Own demand plus the plain sum of all resolved downstream boards. */
cumulatedDemandPowerKw: number;
}
export function assertPrintRevisionConsistency(
revision: number,
summaries: readonly PrintBoardSummary[],
finalRevision: number
) {
if (finalRevision !== revision || summaries.some(({ tree }) => tree.currentRevision !== revision)) {
throw new Error("Das Projekt wurde während des Ladens geändert. Bitte die Seitenansicht neu laden.");
}
}
/**
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
* decided. A board is shown below another board only when its `fedFrom`
* text equals that board's number or name (case-insensitive). The
* cumulated value is a plain sum without an upstream simultaneity factor.
* Unresolved texts and cycles keep a board at the top level.
*/
export function buildPreliminaryBoardHierarchy(
summaries: PrintBoardSummary[]
): PrintBoardHierarchyEntry[] {
const normalize = (value: string | null) =>
value?.trim().toLocaleLowerCase("de") || null;
const parentById = new Map<string, string | null>();
for (const summary of summaries) {
const fedFrom = normalize(summary.board.fedFrom);
const candidates = fedFrom
? summaries.filter(
(candidate) =>
candidate !== summary &&
(normalize(candidate.board.boardNumber) === fedFrom ||
normalize(candidate.board.name) === fedFrom)
)
: [];
const parent = candidates.length === 1 ? candidates[0] : undefined;
parentById.set(summary.board.id, parent?.board.id ?? null);
}
for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]);
let current = parentById.get(summary.board.id) ?? null;
while (current !== null) {
if (visited.has(current)) {
parentById.set(summary.board.id, null);
break;
}
visited.add(current);
current = parentById.get(current) ?? null;
}
}
const childrenOf = (boardId: string | null) =>
summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId
);
const cumulated = (summary: PrintBoardSummary): number =>
summary.demandPowerKw +
childrenOf(summary.board.id).reduce(
(sum, child) => sum + cumulated(child),
0
);
const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({
summary,
depth,
parentBoardId: parentById.get(summary.board.id) ?? null,
cumulatedDemandPowerKw: cumulated(summary),
});
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
};
childrenOf(null).forEach((root) => walk(root, 0));
return entries;
}
const kilowattFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const unitPowerFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
const currentFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
export const formatKilowatt = (value: number) => kilowattFormat.format(value);
export const formatUnitPower = (value: number) => unitPowerFormat.format(value);
export const formatFactor = (value: number) => kilowattFormat.format(value);
export const formatCurrent = (value: number) => currentFormat.format(value);
export function formatProtectionDevice(
protection: CircuitTreeProtectionDeviceDto | undefined
): string | null {
if (!protection) {
return null;
}
const parts = [
protectionDeviceTypeLabels[protection.type],
protection.tripCharacteristic,
`${protection.ratedCurrentA} A`,
protection.fuseUtilizationCategory,
].filter(Boolean);
let text = parts.join(" ");
if (protection.ratedResidualCurrentMa !== undefined) {
text += `, ${protection.ratedResidualCurrentMa} mA`;
if (protection.rcdType) {
text += ` Typ ${protection.rcdType}`;
}
}
return text;
}
export function formatCircuitCable(circuit: CircuitTreeCircuitDto): string | null {
return [circuit.cableType, circuit.cableCrossSection].filter(Boolean).join(" · ") || null;
}

View file

@ -0,0 +1,481 @@
/* Print view (Seitenansicht) in the kbp corporate design. Colors are
derived from the kbp logo and presentation material; change them only
here.
All selectors are scoped to .print-view. The @page rules (format, margins,
page numbers) are injected by the print page because they need project
data. */
.print-view {
--pd-blue: #476088;
--pd-lilac: #9198c4;
--pd-line: #d6dae5;
--pd-tint: #f5f6fa;
--pd-text: #2b2b2b;
--pd-muted: #6b7280;
--pd-accent: #c9782a;
--pd-font: Arial, "Helvetica Neue", Helvetica, sans-serif;
--pd-landscape-width: 297mm;
--pd-landscape-height: 210mm;
min-height: 100vh;
background: #e7e9ef;
color: var(--pd-text);
font-family: var(--pd-font);
color-scheme: light;
}
.print-view.pd-a3 {
--pd-landscape-width: 420mm;
--pd-landscape-height: 297mm;
}
.pd-toolbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid var(--pd-line);
font-size: 14px;
}
.pd-toolbar-title {
margin-right: auto;
color: var(--pd-blue);
font-weight: bold;
}
.pd-toolbar .pd-toolbar-message {
color: var(--pd-muted);
font-size: 13px;
}
.pd-toolbar .pd-toolbar-error {
color: #b04035;
font-size: 13px;
}
.pd-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 24px 16px 48px;
overflow-x: auto;
}
.pd-sheet {
position: relative;
flex: none;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
font-size: 9pt;
line-height: 1.3;
}
.pd-portrait {
width: 210mm;
min-height: 297mm;
padding: 18mm 18mm 22mm;
page: lb-portrait;
}
.pd-landscape {
width: var(--pd-landscape-width);
min-height: var(--pd-landscape-height);
padding: 12mm 12mm 18mm;
page: lb-landscape;
}
.pd-a3 .pd-landscape {
font-size: 10pt;
}
.print-view table {
border-collapse: collapse;
width: 100%;
}
.print-view caption {
caption-side: top;
padding: 0 0 1.5mm;
color: var(--pd-blue);
font-size: 10pt;
font-weight: bold;
letter-spacing: 0.04em;
text-align: left;
text-transform: uppercase;
}
.print-view sub {
font-size: 0.75em;
}
.pd-r {
text-align: right !important;
}
.pd-placeholder {
color: var(--pd-accent);
font-style: italic;
}
.pd-open {
color: var(--pd-accent);
font-weight: bold;
}
.pd-multiline {
white-space: pre-line;
}
.pd-logo {
display: block;
max-width: 100%;
object-fit: contain;
}
.pd-logo-missing {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--pd-line);
color: var(--pd-muted);
font-size: 8pt;
}
/* Cover */
.pd-cover-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid var(--pd-blue);
padding-bottom: 6mm;
}
.pd-cover-kicker {
margin-top: 3mm;
color: var(--pd-muted);
font-size: 10pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pd-cover-logo {
height: 15mm;
width: auto;
max-width: 60mm;
}
span.pd-cover-logo {
width: 40mm;
}
.pd-cover-title {
margin: 14mm 0 10mm;
}
.pd-cover-title h1 {
margin: 0;
color: var(--pd-blue);
font-size: 30pt;
font-weight: bold;
}
.pd-cover-title p {
margin: 2mm 0 0;
color: var(--pd-muted);
font-size: 13pt;
}
.pd-kv {
margin-bottom: 7mm;
}
.pd-kv th,
.pd-kv td {
border: 1px solid var(--pd-line);
padding: 2mm 3mm;
font-size: 9.5pt;
text-align: left;
vertical-align: top;
}
.pd-kv th {
width: 42mm;
background: var(--pd-tint);
color: var(--pd-muted);
font-weight: normal;
}
.pd-cover-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6mm;
}
.pd-cover-cols .pd-kv th {
width: 30mm;
}
.pd-block {
margin-bottom: 7mm;
}
.pd-col-index {
width: 14mm;
}
/* Generic grid tables */
.pd-grid th,
.pd-grid td {
border: 1px solid var(--pd-line);
padding: 1.3mm 2mm;
text-align: left;
vertical-align: top;
}
.pd-grid > thead > tr > th:not(.pd-tb-cell) {
background: var(--pd-blue);
border-color: var(--pd-blue);
color: #fff;
font-weight: bold;
vertical-align: bottom;
}
/* Footer (screen only; printed pages use @page margin boxes) */
.pd-sheet-footer {
position: absolute;
left: 12mm;
right: 12mm;
bottom: 7mm;
display: flex;
justify-content: space-between;
border-top: 1px solid var(--pd-line);
padding-top: 2mm;
color: var(--pd-muted);
font-size: 7.5pt;
}
.pd-portrait .pd-sheet-footer {
left: 18mm;
right: 18mm;
bottom: 10mm;
}
/* Title block */
.pd-grid th.pd-tb-cell {
border: none;
padding: 0 0 4mm;
background: none;
font-weight: normal;
}
.pd-titleblock {
display: grid;
grid-template-columns: 28mm 1fr auto auto;
border: 1.5px solid var(--pd-blue);
margin-bottom: 4mm;
background: #fff;
font-size: 9pt;
}
.pd-grid .pd-titleblock {
margin-bottom: 0;
}
.pd-titleblock > * {
padding: 2.5mm 3.5mm;
border-left: 1px solid var(--pd-line);
}
.pd-titleblock > :first-child {
border-left: none;
}
.pd-tb-logo {
display: flex;
align-items: center;
justify-content: center;
height: 17mm;
}
.pd-tb-logo-image {
max-height: 10mm;
}
span.pd-tb-logo-image {
width: 100%;
height: 10mm;
}
.pd-tb-kicker {
color: var(--pd-muted);
font-size: 7.5pt;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.pd-tb-title {
display: flex;
align-items: baseline;
gap: 4mm;
margin-top: 1mm;
}
.pd-tb-number {
color: var(--pd-blue);
font-size: 18pt;
font-weight: bold;
}
.pd-tb-name {
font-size: 14pt;
font-weight: bold;
}
.pd-tb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 0.8mm 4mm;
align-content: center;
margin: 0;
font-size: 8pt;
}
.pd-tb-grid div {
display: contents;
}
.pd-tb-grid dt {
color: var(--pd-muted);
font-weight: normal;
}
.pd-tb-grid dd {
margin: 0;
font-weight: bold;
}
.pd-tb-meta {
background: var(--pd-tint);
}
/* Board plan table */
.pd-plan {
font-size: 7.6pt;
line-height: 1.2;
}
.pd-a3 .pd-plan {
font-size: 9pt;
}
.pd-plan th,
.pd-plan td {
padding: 0.75mm 1.6mm;
}
.pd-plan td.pd-bmk {
color: var(--pd-blue);
font-weight: bold;
white-space: nowrap;
}
.pd-plan tr.pd-group td {
background: var(--pd-tint);
border-top: 1.5px solid var(--pd-lilac);
font-weight: bold;
}
.pd-plan tr.pd-component td,
.pd-plan tr.pd-component td.pd-bmk,
.pd-plan tr.pd-device td {
color: var(--pd-muted);
}
.pd-plan tr.pd-device td:nth-child(2) {
padding-left: 6mm;
}
.pd-plan tr.pd-reserve td {
color: var(--pd-muted);
font-style: italic;
}
.pd-plan tr.pd-subtotal td {
border-bottom: 1.5px solid var(--pd-line);
font-weight: bold;
}
.pd-plan .pd-sub {
color: var(--pd-muted);
font-size: 0.9em;
}
.pd-plan tfoot td {
background: var(--pd-tint);
font-weight: bold;
}
.pd-plan tfoot tr.pd-grand td {
background: var(--pd-blue);
border-color: var(--pd-blue);
color: #fff;
}
.pd-plan tr {
break-inside: avoid;
}
/* Overview */
.pd-overview {
display: grid;
grid-template-columns: 62mm 1fr;
gap: 6mm;
}
.pd-a3 .pd-overview {
grid-template-columns: 90mm 1fr;
}
.pd-structure h2 {
margin: 0 0 3mm;
color: var(--pd-blue);
font-size: 10pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pd-tree,
.pd-tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.pd-tree ul {
margin-left: 5mm;
padding-left: 4mm;
border-left: 1.5px solid var(--pd-lilac);
}
.pd-tree li {
position: relative;
margin: 2.5mm 0;
}
.pd-tree ul > li::before {
content: "";
position: absolute;
left: -4mm;
top: 5mm;
width: 4mm;
border-top: 1.5px solid var(--pd-lilac);
}
.pd-node {
display: grid;
gap: 0.3mm;
padding: 1.5mm 2.5mm;
border: 1px solid var(--pd-line);
border-left: 3px solid var(--pd-blue);
background: #fff;
}
.pd-node.pd-feed {
border-left-color: var(--pd-accent);
}
.pd-node span {
color: var(--pd-muted);
font-size: 8pt;
}
.pd-node em {
color: var(--pd-blue);
font-size: 8.5pt;
font-style: normal;
font-weight: bold;
}
.pd-note {
margin: 4mm 0 1mm;
padding: 2.5mm 3mm;
border-left: 3px solid var(--pd-accent);
background: #fbf4ec;
font-size: 8pt;
}
.pd-legend {
margin: 1mm 0 0;
color: var(--pd-muted);
font-size: 7.5pt;
}
@media print {
html,
body,
.print-view {
background: #fff !important;
}
/* The dark theme sets color-scheme: dark on the root; Chrome then paints
the page margins dark. Printing always uses the light scheme. */
html:has(.print-view) {
color-scheme: light !important;
}
.pd-toolbar,
.pd-sheet-footer {
display: none !important;
}
.pd-pages {
display: block;
padding: 0;
overflow: visible;
}
.pd-sheet {
width: auto;
min-height: 0;
padding: 0;
box-shadow: none;
break-after: page;
}
.pd-sheet:last-child {
break-after: auto;
}
.print-view * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

View file

@ -0,0 +1,625 @@
"use client";
import type { ReactNode } from "react";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
OfficeProfileDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../types";
import {
formatCircuitCable,
formatCurrent,
formatFactor,
formatKilowatt,
formatProtectionDevice,
formatUnitPower,
type PrintBoardHierarchyEntry,
} from "./print-document-model";
export interface PrintDocumentProps {
project: ProjectDto;
office: OfficeProfileDto;
entries: PrintBoardHierarchyEntry[];
namedSnapshots: ProjectSnapshotMetadataDto[];
logoSrc: string | null;
printedAt: Date;
}
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
if (!value) {
return <Placeholder />;
}
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
}
const dateFormat = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function Placeholder({ label = "ergänzen" }: { label?: string }) {
return <span className="pd-placeholder">[{label}]</span>;
}
function Open() {
return <span className="pd-open">offen</span>;
}
function Logo({ src, className }: { src: string | null; className: string }) {
return src ? (
// eslint-disable-next-line @next/next/no-img-element
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
) : (
<span className={`pd-logo-missing ${className}`}>Logo</span>
);
}
function SheetFooter({ project, stand }: { project: ProjectDto; stand: string }) {
return (
<div className="pd-sheet-footer" aria-hidden="true">
<span>{project.name} · Leistungsbilanz</span>
<span>
Stand {stand} · Datenstand Rev. {project.currentRevision}
</span>
</div>
);
}
function TitleBlock({
kicker,
number,
name,
rows,
project,
stand,
logoSrc,
}: {
kicker: string;
number?: string | null;
name: string;
rows: Array<[ReactNode, ReactNode]>;
project: ProjectDto;
stand: string;
logoSrc: string | null;
}) {
return (
<div className="pd-titleblock">
<div className="pd-tb-logo">
<Logo className="pd-tb-logo-image" src={logoSrc} />
</div>
<div className="pd-tb-main">
<div className="pd-tb-kicker">{kicker}</div>
<div className="pd-tb-title">
{number ? <span className="pd-tb-number">{number}</span> : null}
<span className="pd-tb-name">{name}</span>
</div>
</div>
<dl className="pd-tb-grid">
{rows.map(([label, value], index) => (
<div key={index}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<dl className="pd-tb-grid pd-tb-meta">
<div>
<dt>Projekt</dt>
<dd>{project.name}</dd>
</div>
<div>
<dt>Projekt-Nr.</dt>
<dd>{project.internalProjectNumber ?? "–"}</dd>
</div>
<div>
<dt>Stand</dt>
<dd>{stand}</dd>
</div>
<div>
<dt>Datenstand</dt>
<dd>Rev. {project.currentRevision}</dd>
</div>
</dl>
</div>
);
}
function boardLabel(entry: PrintBoardHierarchyEntry) {
const { board } = entry.summary;
return board.boardNumber ? `${board.boardNumber} – ${board.name}` : board.name;
}
function CoverSheet({
project,
office,
entries,
namedSnapshots,
logoSrc,
stand,
}: Omit<PrintDocumentProps, "printedAt"> & { stand: string }) {
return (
<section className="pd-sheet pd-portrait pd-cover">
<div className="pd-cover-head">
<div className="pd-cover-kicker">Projektdokumentation Elektrotechnik</div>
<Logo className="pd-cover-logo" src={logoSrc} />
</div>
<div className="pd-cover-title">
<h1>Leistungsbilanz</h1>
<p>Verteilerübersicht und Verteilerpläne</p>
</div>
<table className="pd-kv">
<caption>Projekt</caption>
<tbody>
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
</tbody>
</table>
<div className="pd-cover-cols">
<table className="pd-kv">
<caption>Planverfasser</caption>
<tbody>
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
</tbody>
</table>
<table className="pd-kv">
<caption>Dokument</caption>
<tbody>
<tr><th>Dokumentart</th><td>Leistungsbilanz</td></tr>
<tr><th>Stand</th><td>{stand}</td></tr>
<tr><th>Datenstand</th><td>Revision {project.currentRevision}</td></tr>
<tr><th>Verteiler</th><td>{entries.length}</td></tr>
</tbody>
</table>
</div>
<table className="pd-grid pd-block">
<caption>Planstände</caption>
<thead>
<tr><th className="pd-col-index">Index</th><th>Datum</th><th>Änderung</th><th>Bearbeitung</th></tr>
</thead>
<tbody>
{namedSnapshots.length ? (
namedSnapshots.map((snapshot, index) => (
<tr key={snapshot.id}>
<td>{String.fromCharCode(97 + (index % 26))}</td>
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
<td>{snapshot.name}</td>
{/* Snapshots do not record an editor yet; the project's
current editor is shown for every entry. */}
<td><Value value={project.projectEditor} /></td>
</tr>
))
) : (
<tr><td colSpan={4}>–</td></tr>
)}
</tbody>
</table>
<table className="pd-grid pd-block">
<caption>Inhalt</caption>
<tbody>
<tr><td className="pd-col-index">1</td><td>Verteilerübersicht und Leistungssummen</td></tr>
{entries.map((entry, index) => (
<tr key={entry.summary.board.id}>
<td className="pd-col-index">2.{index + 1}</td>
<td>Verteilerplan {boardLabel(entry)}</td>
</tr>
))}
</tbody>
</table>
<SheetFooter project={project} stand={stand} />
</section>
);
}
function StructureNode({
entry,
entries,
}: {
entry: PrintBoardHierarchyEntry;
entries: PrintBoardHierarchyEntry[];
}) {
const children = entries.filter(
(candidate) => candidate.parentBoardId === entry.summary.board.id
);
const { board } = entry.summary;
return (
<li>
<div className="pd-node">
<strong>{board.boardNumber ?? board.name}</strong>
{board.boardNumber ? <span>{board.name}</span> : null}
<em>{formatKilowatt(entry.summary.demandPowerKw)} kW</em>
</div>
{children.length ? (
<ul>
{children.map((child) => (
<StructureNode entries={entries} entry={child} key={child.summary.board.id} />
))}
</ul>
) : null}
</li>
);
}
function OverviewSheet({
project,
entries,
logoSrc,
stand,
}: {
project: ProjectDto;
entries: PrintBoardHierarchyEntry[];
logoSrc: string | null;
stand: string;
}) {
const roots = entries.filter((entry) => entry.parentBoardId === null);
// Top-level boards whose "fed from" text names no other board are grouped
// below that text as an external feed (e.g. "HAK / Zähleranlage").
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
for (const root of roots) {
const feed = root.summary.board.fedFrom ?? "";
feedGroups.set(feed, [...(feedGroups.get(feed) ?? []), root]);
}
const sum =(pick: (entry: PrintBoardHierarchyEntry) => number) =>
entries.reduce((total, entry) => total + pick(entry), 0);
return (
<section className="pd-sheet pd-landscape">
<TitleBlock
kicker="Übersicht"
logoSrc={logoSrc}
name="Verteilerübersicht und Leistungssummen"
project={project}
rows={[
["Verteiler", String(entries.length)],
["Netz", `${project.threePhaseVoltageV} / ${project.singlePhaseVoltageV} V`],
]}
stand={stand}
/>
<div className="pd-overview">
<div className="pd-structure">
<h2>Versorgungsstruktur</h2>
<ul className="pd-tree">
{[...feedGroups].map(([feed, feedRoots]) => {
const nodes = feedRoots.map((root) => (
<StructureNode entries={entries} entry={root} key={root.summary.board.id} />
));
return feed ? (
<li key={`feed:${feed}`}>
<div className="pd-node pd-feed">
<strong>Einspeisung</strong>
<span>{feed}</span>
</div>
<ul>{nodes}</ul>
</li>
) : (
nodes
);
})}
</ul>
</div>
<div>
<table className="pd-grid pd-plan">
<thead>
<tr>
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
<th className="pd-r">SK</th>
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
<th className="pd-r">GZF<br />Vert.</th>
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
<th className="pd-r">I<sub>B</sub><br />[A]</th>
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const { board } = entry.summary;
return (
<tr key={board.id}>
<td style={{ paddingLeft: `${1.6 + entry.depth * 3.5}mm` }}>
{entry.depth ? "└ " : ""}
<strong>{board.boardNumber ?? "–"}</strong>
</td>
<td>{board.name}</td>
<td>{board.location ?? "–"}</td>
<td>{entry.summary.floorName ?? "–"}</td>
<td>{board.supplyType ?? "–"}</td>
<td>{board.fedFrom ?? "–"}</td>
<td className="pd-r">{entry.summary.circuitCount}</td>
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
<td />
<td />
</tr>
</tfoot>
</table>
<p className="pd-note">
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
Verknüpfung und Summierung folgt.
</p>
<p className="pd-legend">
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
S aus cos φ je Gerätezeile
</p>
</div>
</div>
<SheetFooter project={project} stand={stand} />
</section>
);
}
const planColumnCount = 11;
function roomLabel(row: CircuitTreeDeviceRowDto) {
return row.roomNumberSnapshot
? `${row.roomNumberSnapshot} ${row.roomNameSnapshot ?? ""}`.trim()
: "–";
}
function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
return (
<tr className="pd-component">
<td className="pd-bmk">{component.equipmentIdentifier}</td>
<td colSpan={6}>{component.name}</td>
<td>{formatProtectionDevice(component.protectionDevice) ?? "–"}</td>
<td colSpan={3} />
</tr>
);
}
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
const rows = circuit.deviceRows;
const circuitCells = (
<>
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
<td>{formatCircuitCable(circuit) ?? (circuit.isReserve ? "–" : <Open />)}</td>
<td className="pd-r">
{circuit.cableLength ?? (circuit.isReserve ? "–" : <Open />)}
</td>
<td>{circuit.remark ?? ""}</td>
</>
);
if (rows.length === 0) {
return (
<tr className="pd-circuit pd-reserve">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName ?? "Reserve"}</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r">–</td>
{circuitCells}
</tr>
);
}
if (rows.length === 1) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;
return (
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>
{title}
{title !== row.displayName ? (
<span className="pd-sub"> · {row.displayName}</span>
) : null}
</td>
<td>{roomLabel(row)}</td>
<td className="pd-r">{row.quantity}</td>
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
);
}
return (
<>
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName ?? ""}</td>
<td /><td /><td /><td />
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
{rows.map((row) => (
<tr className="pd-device" key={row.id}>
<td />
<td>{row.displayName}</td>
<td>{roomLabel(row)}</td>
<td className="pd-r">{row.quantity}</td>
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
<td className="pd-r">{formatKilowatt(row.rowTotalPower)}</td>
<td colSpan={4} />
</tr>
))}
</>
);
}
function BoardPlanSheet({
entry,
project,
logoSrc,
stand,
}: {
entry: PrintBoardHierarchyEntry;
project: ProjectDto;
logoSrc: string | null;
stand: string;
}) {
const { summary } = entry;
const { board, tree } = summary;
return (
<section className="pd-sheet pd-landscape">
<table className="pd-grid pd-plan">
{/* The title block lives in <thead> so it repeats on every printed page. */}
<thead>
<tr>
<th className="pd-tb-cell" colSpan={planColumnCount}>
<TitleBlock
kicker="Verteilerplan"
logoSrc={logoSrc}
name={board.name}
number={board.boardNumber}
project={project}
rows={[
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
["Netzart", board.supplyType ?? "–"],
["Gespeist von", board.fedFrom ?? "–"],
[
<>P<sub>gl</sub></>,
<>
{formatKilowatt(summary.demandPowerKw)} kW · I<sub>B</sub>{" "}
{formatCurrent(summary.designCurrentA)} A
</>,
],
]}
stand={stand}
/>
</th>
</tr>
<tr className="pd-head">
<th>SK-Nr.</th><th>Bezeichnung</th><th>Raum</th>
<th className="pd-r">Anz.</th>
<th className="pd-r">P/Stk.<br />[kW]</th>
<th className="pd-r">GZF</th>
<th className="pd-r">P<br />[kW]</th>
<th>Schutz</th><th>Kabel</th>
<th className="pd-r">Länge<br />[m]</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
{tree.headerComponents.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
{tree.sections.map((section) => (
<SectionRows key={section.id} section={section} />
))}
{tree.footerComponents.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
</tbody>
<tfoot>
<tr>
<td />
<td colSpan={5}>
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
</td>
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
<td colSpan={4} />
</tr>
<tr>
<td />
<td colSpan={5}>Gleichzeitigkeitsfaktor Verteiler</td>
<td className="pd-r">{formatFactor(summary.simultaneityFactor)}</td>
<td colSpan={4} />
</tr>
<tr className="pd-grand">
<td />
<td colSpan={5}>Gleichzeitige Leistung P<sub>gl</sub></td>
<td className="pd-r">{formatKilowatt(summary.demandPowerKw)}</td>
<td colSpan={4}>I<sub>B</sub> ≈ {formatCurrent(summary.designCurrentA)} A</td>
</tr>
</tfoot>
</table>
<SheetFooter project={project} stand={stand} />
</section>
);
}
function SectionRows({
section,
}: {
section: PrintBoardHierarchyEntry["summary"]["tree"]["sections"][number];
}) {
return (
<>
<tr className="pd-group">
<td className="pd-bmk">{section.prefix}</td>
<td colSpan={planColumnCount - 1}>{section.displayName}</td>
</tr>
{section.components.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
{section.circuits.map((circuit) => (
<CircuitRows circuit={circuit} key={circuit.id} />
))}
<tr className="pd-subtotal">
<td />
<td colSpan={5}>Summe {section.displayName}</td>
<td className="pd-r">{formatKilowatt(section.sectionTotalPower)}</td>
<td colSpan={4} />
</tr>
</>
);
}
export function PrintDocument({
project,
office,
entries,
namedSnapshots,
logoSrc,
printedAt,
}: PrintDocumentProps) {
const stand = dateFormat.format(printedAt);
return (
<div className="pd-pages">
<CoverSheet
entries={entries}
logoSrc={logoSrc}
namedSnapshots={namedSnapshots}
office={office}
project={project}
stand={stand}
/>
<OverviewSheet entries={entries} logoSrc={logoSrc} project={project} stand={stand} />
{entries.map((entry) => (
<BoardPlanSheet
entry={entry}
key={entry.summary.board.id}
logoSrc={logoSrc}
project={project}
stand={stand}
/>
))}
</div>
);
}

View file

@ -93,9 +93,18 @@ export interface ProjectDto {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
siteAddress: string | null;
workStage: string | null;
projectLead: string | null;
projectEditor: string | null;
currentRevision: number;
}
export interface OfficeProfileDto {
officeName: string | null;
officeAddress: string | null;
}
export interface ProjectHistoryStateDto {
projectId: string;
currentRevision: number;
@ -172,7 +181,14 @@ export interface ProjectCommandResultDto {
history: ProjectHistoryStateDto;
}
export interface DistributionBoardDto {
export interface DistributionBoardInfoDto {
boardNumber: string | null;
location: string | null;
fedFrom: string | null;
remark: string | null;
}
export interface DistributionBoardDto extends DistributionBoardInfoDto {
id: string;
projectId: string;
name: string;
@ -181,6 +197,13 @@ export interface DistributionBoardDto {
simultaneityFactor: number;
}
export interface DocumentLogoMetadataDto {
mimeType: string;
fileName: string | null;
sizeBytes: number;
updatedAtIso: string;
}
export interface DistributionBoardCommandResultDto
extends ProjectCommandResultDto {
distributionBoard: DistributionBoardDto;

View file

@ -8,7 +8,10 @@ import type {
DistributionBoardCommandResultDto,
DistributionBoardCopyCommandResultDto,
DistributionBoardDeleteCommandResultDto,
DistributionBoardInfoDto,
DocumentLogoMetadataDto,
FloorDto,
OfficeProfileDto,
GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
@ -256,7 +259,7 @@ export function updateProjectSettings(
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
}
} & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">>
) {
return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`,
@ -329,7 +332,7 @@ export function updateDistributionBoard(
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
},
} & Partial<DistributionBoardInfoDto>,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
@ -341,6 +344,42 @@ export function updateDistributionBoard(
);
}
export const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() {
return request<{
logo: DocumentLogoMetadataDto | null;
office: OfficeProfileDto;
}>("/api/document-branding");
}
export function updateOfficeProfile(office: OfficeProfileDto) {
return request<{ office: OfficeProfileDto }>("/api/document-branding/office", {
method: "PUT",
body: JSON.stringify(office),
});
}
export async function uploadDocumentLogo(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return request<{ logo: DocumentLogoMetadataDto }>(documentLogoUrl, {
method: "PUT",
body: JSON.stringify({
fileName: file.name,
mimeType: file.type,
contentBase64: btoa(binary),
}),
});
}
export function deleteDocumentLogo() {
return request<void>(documentLogoUrl, { method: "DELETE" });
}
export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration`