Add print view, board info card and document logo upload

- Seitenansicht (/projects/:projectId/print): tabular cover page, board
  overview with supply structure and power sums, one plan per board with a
  repeating title block; PDF via the browser print dialog with named @page
  rules (A4 portrait cover, A4/A3 landscape plans, page x of y).
- Distribution-board info card (board number, location, fed-from, remark)
  in the board edit dialog, persisted through the additively extended
  distribution-board.update command (migration 0008, snapshot schema 6,
  legacy subtree commands normalized).
- Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project
  revisions and snapshots.
- fed-from stays free text; the overview hierarchy and cumulated sums are
  preliminary until the feed link is modeled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-25 19:31:04 +02:00
parent 2742b26b87
commit 94688d7a8c
38 changed files with 5301 additions and 32 deletions

View file

@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildPreliminaryBoardHierarchy,
formatProtectionDevice,
summarizePrintBoard,
} from "../src/frontend/print/print-document-model.js";
import type {
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../src/frontend/types.js";
function board(
id: string,
info: Partial<DistributionBoardDto> = {}
): DistributionBoardDto {
return {
id,
projectId: "project-1",
name: id,
floorId: null,
supplyType: "AV",
simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
...info,
};
}
function tree(
rows: Array<{
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
}>,
boardSimultaneityFactor = 1
): CircuitTreeResponseDto {
const deviceRows = rows.map((row, index) => ({
id: `row-${index}`,
sortOrder: index,
name: "Gerät",
displayName: "Gerät",
manualQuantity: row.quantity,
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
...row,
}));
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
return {
circuitListId: "list",
currentRevision: 1,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
distributionBoardTotalPower: total,
distributionBoardTotalPowerWithSimultaneityFactor:
total * boardSimultaneityFactor,
headerComponents: [],
footerComponents: [],
sections: [
{
id: "section",
key: "single_phase",
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 1,
sectionTotalPower: total,
components: [],
circuits: [
{
id: "circuit",
circuitListId: "list",
sectionId: "section",
equipmentIdentifier: "-2F1.1",
sortOrder: 1,
isReserve: false,
circuitTotalPower: total,
deviceRows,
},
],
},
],
};
}
function summary(
id: string,
info: Partial<DistributionBoardDto>,
demandKw: number
) {
return summarizePrintBoard({
board: board(id, info),
circuitListId: id,
floorName: null,
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
});
}
describe("print document model", () => {
it("derives installed, device and demand power and the design current", () => {
const result = summarizePrintBoard({
board: board("uv"),
circuitListId: "uv",
floorName: "EG",
tree: tree(
[
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
],
0.5
),
});
assert.equal(result.circuitCount, 1);
assert.equal(result.installedPowerKw, 14);
assert.equal(result.devicePowerKw, 10);
assert.equal(result.demandPowerKw, 5);
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
});
it("nests boards by matching free-text feed and sums downstream demand", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
]);
assert.deepEqual(
entries.map((entry) => [
entry.summary.board.id,
entry.depth,
entry.parentBoardId,
entry.cumulatedDemandPowerKw,
]),
[
["hv", 0, null, 16],
["uv-a", 1, "hv", 6],
["uv-b", 2, "uv-a", 2],
["uv-c", 0, null, 1],
]
);
});
it("keeps boards with cyclic feed texts at the top level", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
]);
assert.equal(entries.length, 2);
assert.ok(entries.some((entry) => entry.parentBoardId === null));
assert.equal(
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
2
);
});
it("formats protection devices for the plan table", () => {
assert.equal(
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
"LS B 16 A"
);
assert.equal(
formatProtectionDevice({
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
}),
"FI/LS B 16 A, 30 mA Typ A"
);
assert.equal(formatProtectionDevice(undefined), null);
});
});