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
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
110
src/frontend/components/distribution-board-info-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
191
src/frontend/print/print-document-model.ts
Normal file
191
src/frontend/print/print-document-model.ts
Normal 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;
|
||||
}
|
||||
481
src/frontend/print/print-document.css
Normal file
481
src/frontend/print/print-document.css
Normal 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;
|
||||
}
|
||||
}
|
||||
625
src/frontend/print/print-document.tsx
Normal file
625
src/frontend/print/print-document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue