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 2e3e85a8e5
commit 4779e4a1a7
40 changed files with 5297 additions and 33 deletions

View file

@ -54,6 +54,12 @@ import {
type DistributionBoardSupplyType,
} from "../../../shared/constants/distribution-board";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
DistributionBoardInfoCard,
fromDistributionBoardInfoDraft,
toDistributionBoardInfoDraft,
type DistributionBoardInfoDraft,
} from "../../../frontend/components/distribution-board-info-card";
import {
ProjectSettingsModal,
type ProjectSettingsInput,
@ -101,6 +107,15 @@ export default function ProjectDetailPage() {
setEditingBoardSimultaneityFactor,
] = useState("1");
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
})
);
const [floorName, setFloorName] = useState("");
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
@ -424,6 +439,7 @@ export default function ProjectDetailPage() {
String(board.simultaneityFactor)
);
setEditingBoardCopyName(`${board.name} Kopie`);
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
}
function openBoardCreator() {
@ -459,6 +475,7 @@ export default function ProjectDetailPage() {
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
simultaneityFactor,
...fromDistributionBoardInfoDraft(editingBoardInfo),
},
project.currentRevision
);
@ -849,6 +866,13 @@ export default function ProjectDetailPage() {
</p>
</div>
<div className="d-flex gap-2">
<Link
className="btn btn-primary"
href={`/projects/${projectId}/print`}
target="_blank"
>
Seitenansicht
</Link>
<button
className="btn btn-outline-primary"
disabled={!project}
@ -922,7 +946,9 @@ export default function ProjectDetailPage() {
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Nr.</th>
<th>Verteilung</th>
<th>Örtlichkeit</th>
<th>Etage</th>
<th>Netzart</th>
<th>GZF</th>
@ -934,7 +960,11 @@ export default function ProjectDetailPage() {
const circuitList = circuitListByBoardId.get(board.id);
return (
<tr key={board.id}>
<td className="fw-semibold">
{board.boardNumber ?? "–"}
</td>
<td>{board.name}</td>
<td>{board.location ?? "–"}</td>
<td>
{board.floorId
? floorById.get(board.floorId)?.name ?? "Unbekannt"
@ -985,7 +1015,7 @@ export default function ProjectDetailPage() {
})}
{!boards.length ? (
<tr>
<td colSpan={5} className="text-center text-secondary py-4">
<td colSpan={7} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden.
</td>
</tr>
@ -1464,6 +1494,12 @@ export default function ProjectDetailPage() {
</div>
</div>
</div>
<div className="mt-4">
<DistributionBoardInfoCard
draft={editingBoardInfo}
onChange={setEditingBoardInfo}
/>
</div>
<hr className="my-4" />
<section aria-labelledby="copy-distribution-board-heading">
<h3

View file

@ -0,0 +1,261 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChangeEvent, useEffect, useMemo, useState } from "react";
import "../../../../frontend/print/print-document.css";
import {
deleteDocumentLogo,
documentLogoUrl,
getCircuitTree,
getDocumentBranding,
getProject,
listCircuitLists,
listDistributionBoards,
listFloors,
listProjectSnapshots,
uploadDocumentLogo,
} from "../../../../frontend/utils/api";
import type {
DocumentLogoMetadataDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../../../../frontend/types";
import { PrintDocument } from "../../../../frontend/print/print-document";
import {
buildPreliminaryBoardHierarchy,
summarizePrintBoard,
type PrintBoardSummary,
} from "../../../../frontend/print/print-document-model";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../../../../shared/validation/document-branding.schemas";
type PlanFormat = "a4" | "a3";
interface PrintData {
project: ProjectDto;
summaries: PrintBoardSummary[];
namedSnapshots: ProjectSnapshotMetadataDto[];
}
const boardSortCollator = new Intl.Collator("de", { numeric: true });
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
function pageRules(project: ProjectDto, stand: string, format: PlanFormat) {
const marginBoxes = `
@bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }`;
return `
@page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} }
@page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`;
}
export default function ProjectPrintPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [data, setData] = useState<PrintData | null>(null);
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
const [format, setFormat] = useState<PlanFormat>("a4");
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [printedAt] = useState(() => new Date());
useEffect(() => {
if (!projectId) {
return;
}
let cancelled = false;
async function load() {
const [project, boards, circuitLists, floors, snapshots, branding] =
await Promise.all([
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listFloors(projectId),
listProjectSnapshots(projectId),
getDocumentBranding(),
]);
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const summaries = await Promise.all(
[...boards]
.sort(
(left, right) =>
boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") ||
boardSortCollator.compare(left.name, right.name)
)
.flatMap((board) => {
const circuitList = circuitLists.find(
(list) => list.distributionBoardId === board.id
);
return circuitList ? [{ board, circuitList }] : [];
})
.map(async ({ board, circuitList }) =>
summarizePrintBoard({
board,
circuitListId: circuitList.id,
tree: await getCircuitTree(projectId, circuitList.id),
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
})
)
);
if (cancelled) {
return;
}
setData({
project,
summaries,
namedSnapshots: snapshots
.filter((snapshot) => snapshot.kind === "named")
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
});
setLogo(branding.logo);
document.title = `Leistungsbilanz – ${project.name}`;
}
load().catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden.");
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const entries = useMemo(
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []),
[data]
);
const stand = useMemo(
() =>
new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(printedAt),
[printedAt]
);
const logoSrc = logo
? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}`
: null;
async function handleLogoUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) {
setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen.");
return;
}
if (file.size > maxDocumentLogoBytes) {
setError("Das Logo darf höchstens 2 MB groß sein.");
return;
}
setIsBusy(true);
setError(null);
try {
const result = await uploadDocumentLogo(file);
setLogo(result.logo);
setMessage("Logo hinterlegt. Es gilt für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleLogoDelete() {
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
return;
}
setIsBusy(true);
setError(null);
try {
await deleteDocumentLogo();
setLogo(null);
setMessage("Logo entfernt.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden.");
} finally {
setIsBusy(false);
}
}
return (
// Paper output: always light, independent of the app's dark mode.
<div
className={format === "a3" ? "print-view pd-a3" : "print-view"}
data-bs-theme="light"
>
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
<div className="pd-toolbar">
<span className="pd-toolbar-title">
Seitenansicht{data ? ` · ${data.project.name}` : ""}
</span>
{message ? <span className="pd-toolbar-message">{message}</span> : null}
{error ? <span className="pd-toolbar-error">{error}</span> : null}
<label className="d-flex align-items-center gap-2">
Planformat
<select
className="form-select form-select-sm w-auto"
onChange={(event) => setFormat(event.target.value as PlanFormat)}
value={format}
>
<option value="a4">A4 quer</option>
<option value="a3">A3 quer</option>
</select>
</label>
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
<input
accept={documentLogoMimeTypes.join(",")}
disabled={isBusy}
hidden
onChange={(event) => void handleLogoUpload(event)}
type="file"
/>
</label>
{logo ? (
<button
className="btn btn-sm btn-outline-secondary"
disabled={isBusy}
onClick={() => void handleLogoDelete()}
type="button"
>
Logo entfernen
</button>
) : null}
<button
className="btn btn-sm btn-primary"
disabled={!data}
onClick={() => window.print()}
type="button"
>
Als PDF speichern / Drucken
</button>
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
Zum Projekt
</Link>
</div>
{data ? (
<PrintDocument
entries={entries}
logoSrc={logoSrc}
namedSnapshots={data.namedSnapshots}
printedAt={printedAt}
project={data.project}
/>
) : error ? null : (
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
)}
</div>
);
}

View file

@ -0,0 +1,12 @@
CREATE TABLE `document_branding_assets` (
`key` text PRIMARY KEY NOT NULL,
`mime_type` text NOT NULL,
`file_name` text,
`data` blob NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `remark` text;

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,13 @@
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1790411540872,
"tag": "0007_board_info_and_document_logo",
"breakpoints": true
}
]
}

View file

@ -388,10 +388,15 @@ function structureMatches(
) {
const {
simultaneityFactor,
boardNumber,
location,
fedFrom,
remark,
...actualDistributionBoard
} = actual.board;
if (
simultaneityFactor !== 1 ||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard

View file

@ -10,6 +10,7 @@ import {
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import {
assertDistributionBoardSubtreeSnapshot,
normalizeDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import type {
@ -63,19 +64,20 @@ export class DistributionBoardSubtreeProjectCommandRepository
projectId: string,
command: DistributionBoardSubtreeProjectCommand
) {
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
command.payload.snapshot
);
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(
command.payload.snapshot
);
this.insert(database, projectId, snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
}
if (command.type === distributionBoardDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
command.payload.snapshot.distributionBoard.id
snapshot.distributionBoard.id
);
if (!sameSnapshot(current, command.payload.snapshot)) {
if (!sameSnapshot(current, snapshot)) {
throw new Error(
"Distribution-board subtree changed before deletion."
);
@ -97,9 +99,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
"Distribution-board subtree could not be deleted."
);
}
return createDistributionBoardInsertSubtreeProjectCommand(
command.payload.snapshot
);
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
}
throw new Error("Unsupported distribution-board subtree command.");
}

View file

@ -0,0 +1,52 @@
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
documentBrandingAssets,
type DocumentBrandingAsset,
} from "../schema/document-branding-assets.js";
// Plain repository without revision semantics: branding is global office
// configuration, not a project mutation.
export class DocumentBrandingRepository {
constructor(private readonly db: AppDatabase) {}
get(key: string): DocumentBrandingAsset | undefined {
return this.db
.select()
.from(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.get();
}
upsert(asset: {
key: string;
mimeType: string;
fileName: string | null;
data: Buffer;
}): DocumentBrandingAsset {
const updatedAt = new Date();
return this.db
.insert(documentBrandingAssets)
.values({ ...asset, updatedAt })
.onConflictDoUpdate({
target: documentBrandingAssets.key,
set: {
mimeType: asset.mimeType,
fileName: asset.fileName,
data: asset.data,
updatedAt,
},
})
.returning()
.get();
}
delete(key: string): boolean {
return (
this.db
.delete(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.run().changes > 0
);
}
}

View file

@ -5,6 +5,7 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
deserializeProjectStateSnapshot,
isSupportedProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@ -138,7 +139,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
if (!isSupportedProjectStateSnapshotSchemaVersion(stored.schemaVersion)) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);

View file

@ -16,5 +16,9 @@ export const distributionBoards = sqliteTable("distribution_boards", {
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
boardNumber: text("board_number"),
location: text("location"),
fedFrom: text("fed_from"),
remark: text("remark"),
});

View file

@ -0,0 +1,18 @@
import { sql } from "drizzle-orm";
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Office-wide branding for printed documents (currently only the logo shown
// on the cover page and plan title blocks). Global application data, not part
// of any project: it is deliberately outside project revisions, logical
// project snapshots and project transfer.
export const documentBrandingAssets = sqliteTable("document_branding_assets", {
key: text("key").primaryKey(),
mimeType: text("mime_type").notNull(),
fileName: text("file_name"),
data: blob("data", { mode: "buffer" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export type DocumentBrandingAsset = typeof documentBrandingAssets.$inferSelect;

View file

@ -1,14 +1,21 @@
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
// The info-card fields were added additively to schema version 1: every
// previously stored command remains valid, and the inverse is still derived
// from the persisted row for exactly the changed fields.
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues {
export interface DistributionBoardUpdateValues
extends Record<DistributionBoardInfoField, string | null> {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
@ -82,16 +89,20 @@ export function assertDistributionBoardUpdateProjectCommand(
for (const change of changes) {
if (
!isPlainObject(change) ||
typeof change.field !== "string" ||
(change.field !== "floorId" &&
change.field !== "supplyType" &&
change.field !== "simultaneityFactor") ||
change.field !== "simultaneityFactor" &&
!isInfoField(change.field)) ||
seen.has(change.field)
) {
throw new Error(
"Distribution-board update contains an invalid or duplicate field."
);
}
if (change.field === "floorId") {
if (isInfoField(change.field)) {
assertInfoFieldValue(change.field, change.value);
} else if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
@ -121,6 +132,29 @@ export function assertDistributionBoardUpdateProjectCommand(
}
}
function isInfoField(field: string): field is DistributionBoardInfoField {
return (distributionBoardInfoFields as readonly string[]).includes(field);
}
function assertInfoFieldValue(
field: DistributionBoardInfoField,
value: unknown
) {
if (value === null) {
return;
}
if (
typeof value !== "string" ||
!value.trim() ||
value !== value.trim() ||
value.length > distributionBoardInfoFieldMaxLengths[field]
) {
throw new Error(
`${field} must be a trimmed, non-empty string of at most ${distributionBoardInfoFieldMaxLengths[field]} characters or null.`
);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -10,7 +10,9 @@ import {
type DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import {
distributionBoardInfoFields,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type {
@ -72,7 +74,7 @@ export interface DistributionBoardSubtreeSnapshot {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
};
} & Record<DistributionBoardInfoField, string | null>;
circuitList: {
id: string;
projectId: string;
@ -169,6 +171,22 @@ export function cloneDistributionBoardSubtree(
return clone;
}
/**
* Subtree commands stored before the board info fields existed carry no
* info values. They are equivalent to a board whose info fields are empty,
* so comparisons against a freshly captured subtree and re-inserts stay
* exact.
*/
export function normalizeDistributionBoardSubtreeSnapshot(
snapshot: DistributionBoardSubtreeSnapshot
): DistributionBoardSubtreeSnapshot {
const board = { ...snapshot.distributionBoard };
for (const field of distributionBoardInfoFields) {
board[field] = board[field] ?? null;
}
return { ...snapshot, distributionBoard: board };
}
export function assertDistributionBoardSubtreeSnapshot(
value: unknown
): asserts value is DistributionBoardSubtreeSnapshot {
@ -204,6 +222,11 @@ export function assertDistributionBoardSubtreeSnapshot(
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
throw new Error("Distribution-board simultaneity factor is invalid.");
}
for (const field of distributionBoardInfoFields) {
if (board[field] !== undefined) {
assertNullableString(board[field], `distributionBoard.${field}`);
}
}
for (const field of [
"id",
"projectId",

View file

@ -1,5 +1,9 @@
import { z } from "zod";
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board.js";
import {
circuitGroupCategories,
} from "../../shared/constants/circuit-group.js";
@ -27,7 +31,8 @@ import {
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 5 as const;
export const projectStateSnapshotSchemaVersion = 6 as const;
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
const previousProjectStateSnapshotSchemaVersion = 4 as const;
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
@ -62,7 +67,7 @@ const projectSchema = previousProjectSchema
.extend({ isPublicBuilding: z.boolean() })
.strict();
const distributionBoardSchema = z
const legacyDistributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
@ -73,6 +78,35 @@ const distributionBoardSchema = z
})
.strict();
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
z
.string()
.trim()
.min(1)
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable();
const distributionBoardSchema = legacyDistributionBoardSchema
.extend({
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
})
.strict();
function withEmptyDistributionBoardInfo(
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
) {
return boards.map((board) => ({
...board,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
}));
}
const circuitListSchema = z
.object({
id: idSchema,
@ -457,11 +491,22 @@ export const projectStateSnapshotSchema = z
})
.strict();
const boardInfoProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
@ -472,7 +517,7 @@ const externalModelProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents,
})
@ -482,7 +527,7 @@ const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(legacyDistributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
@ -491,7 +536,7 @@ const baselineProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
project: previousProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(legacyDistributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
@ -500,6 +545,20 @@ export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
/**
* Stored snapshots of every supported version can be restored: they are
* upgraded to the current version while parsing.
*/
export function isSupportedProjectStateSnapshotSchemaVersion(
schemaVersion: number
): boolean {
return (
Number.isInteger(schemaVersion) &&
schemaVersion >= baselineProjectStateSnapshotSchemaVersion &&
schemaVersion <= projectStateSnapshotSchemaVersion
);
}
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
@ -521,11 +580,26 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return value;
}
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
// Every supported version before 6 lacks the distribution-board info
// fields; they are upgraded as empty (null) values.
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
};
}
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
};
}
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
@ -533,6 +607,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
externalModel: emptyExternalModelState(),
};
}
@ -541,6 +618,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return {
...legacy,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardInfo(
legacy.distributionBoards
),
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
@ -551,6 +631,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
...baseline,
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false },
distributionBoards: withEmptyDistributionBoardInfo(
baseline.distributionBoards
),
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};

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

@ -0,0 +1,180 @@
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;
}
/**
* 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 parent = fedFrom
? summaries.find(
(candidate) =>
candidate !== summary &&
(normalize(candidate.board.boardNumber) === fedFrom ||
normalize(candidate.board.name) === fedFrom)
)
: 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 || null;
}

View file

@ -0,0 +1,473 @@
/* Print view (Seitenansicht) in the provisional kbp corporate design.
Colors are derived from the kbp logo and presentation material and will be
replaced by the official CD specification; 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-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;
}
.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,612 @@
"use client";
import type { ReactNode } from "react";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../types";
import {
formatCircuitCable,
formatCurrent,
formatFactor,
formatKilowatt,
formatProtectionDevice,
formatUnitPower,
type PrintBoardHierarchyEntry,
} from "./print-document-model";
// Provisional office data until the project/cover fields are added.
export const printOfficeName = "kbp köhler beraten + planen GmbH";
export interface PrintDocumentProps {
project: ProjectDto;
entries: PrintBoardHierarchyEntry[];
namedSnapshots: ProjectSnapshotMetadataDto[];
logoSrc: string | null;
printedAt: Date;
}
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,
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><Placeholder /></td></tr>
<tr><th>Bauherr</th><td>{project.buildingOwner ?? <Placeholder />}</td></tr>
<tr><th>Projektnummer intern</th><td>{project.internalProjectNumber ?? <Placeholder />}</td></tr>
<tr><th>Projektnummer extern</th><td>{project.externalProjectNumber ?? <Placeholder />}</td></tr>
<tr><th>Leistungsphase</th><td><Placeholder /></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>{printOfficeName}</td></tr>
<tr><th>Anschrift</th><td><Placeholder /></td></tr>
<tr><th>Projektleitung</th><td><Placeholder /></td></tr>
<tr><th>Bearbeitung</th><td><Placeholder /></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>
<td><Placeholder /></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 gleichlautende Verteilernummern bzw.
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF). 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,
entries,
namedSnapshots,
logoSrc,
printedAt,
}: PrintDocumentProps) {
const stand = dateFormat.format(printedAt);
return (
<div className="pd-pages">
<CoverSheet
entries={entries}
logoSrc={logoSrc}
namedSnapshots={namedSnapshots}
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

@ -172,7 +172,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 +188,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,6 +8,8 @@ import type {
DistributionBoardCommandResultDto,
DistributionBoardCopyCommandResultDto,
DistributionBoardDeleteCommandResultDto,
DistributionBoardInfoDto,
DocumentLogoMetadataDto,
FloorDto,
GlobalDeviceDto,
ProjectCommandDto,
@ -329,7 +331,7 @@ export function updateDistributionBoard(
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
},
} & Partial<DistributionBoardInfoDto>,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
@ -341,6 +343,34 @@ export function updateDistributionBoard(
);
}
export const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() {
return request<{ logo: DocumentLogoMetadataDto | null }>(
"/api/document-branding"
);
}
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`

View file

@ -13,6 +13,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db);
@ -33,3 +34,4 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);

View file

@ -9,6 +9,7 @@ import {
createDistributionBoardInsertSubtreeProjectCommand,
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import { distributionBoardInfoFields } from "../../shared/constants/distribution-board.js";
import {
copyDistributionBoardSchema,
createDistributionBoardSchema,
@ -64,6 +65,10 @@ export async function createDistributionBoard(req: Request, res: Response) {
distributionBoard: {
...structure.distributionBoard,
simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
},
});
} catch (error) {
@ -86,6 +91,13 @@ export async function updateDistributionBoard(
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
// Info fields are optional in the request; only submitted fields become
// part of the command and therefore of its inverse.
const infoPatch = Object.fromEntries(
distributionBoardInfoFields
.filter((field) => parsed.data[field] !== undefined)
.map((field) => [field, parsed.data[field]])
);
try {
const result = projectCommandService.executeUser({
projectId,
@ -97,6 +109,7 @@ export async function updateDistributionBoard(
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
simultaneityFactor: parsed.data.simultaneityFactor,
...infoPatch,
}
),
});

View file

@ -0,0 +1,84 @@
import type { Request, Response } from "express";
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
import {
maxDocumentLogoBytes,
uploadDocumentLogoSchema,
type DocumentLogoMimeType,
} from "../../shared/validation/document-branding.schemas.js";
import { documentBrandingRepository } from "../composition/application-repositories.js";
const logoKey = "logo";
export function getDocumentBrandingHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
return res.json({ logo: logo ? toLogoMetadata(logo) : null });
}
export function getDocumentLogoHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
if (!logo) {
return res.status(404).json({ error: "No logo uploaded" });
}
res.setHeader("Content-Type", logo.mimeType);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Content-Type-Options", "nosniff");
// SVG uploads are rendered through <img> only; forbid script execution if
// the file is ever opened directly.
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src data:"
);
return res.send(logo.data);
}
export function uploadDocumentLogoHandler(req: Request, res: Response) {
const parsed = uploadDocumentLogoSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const data = Buffer.from(parsed.data.contentBase64, "base64");
if (data.length === 0 || data.length > maxDocumentLogoBytes) {
return res
.status(400)
.json({ error: "Das Logo darf höchstens 2 MB groß sein." });
}
if (!matchesMimeType(data, parsed.data.mimeType)) {
return res.status(400).json({
error: "Der Dateiinhalt passt nicht zum angegebenen Bildformat.",
});
}
const logo = documentBrandingRepository.upsert({
key: logoKey,
mimeType: parsed.data.mimeType,
fileName: parsed.data.fileName,
data,
});
return res.json({ logo: toLogoMetadata(logo) });
}
export function deleteDocumentLogoHandler(_req: Request, res: Response) {
documentBrandingRepository.delete(logoKey);
return res.status(204).end();
}
function toLogoMetadata(logo: DocumentBrandingAsset) {
return {
mimeType: logo.mimeType,
fileName: logo.fileName,
sizeBytes: logo.data.length,
updatedAtIso: logo.updatedAt.toISOString(),
};
}
function matchesMimeType(data: Buffer, mimeType: DocumentLogoMimeType) {
if (mimeType === "image/png") {
return data
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
}
if (mimeType === "image/jpeg") {
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
}
const head = data.subarray(0, 2048).toString("utf8").toLowerCase();
return head.includes("<svg");
}

View file

@ -1,6 +1,7 @@
import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
@ -51,6 +52,7 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/document-branding", documentBrandingRouter);
app.use(errorMiddleware);

View file

@ -0,0 +1,9 @@
import express from "express";
import * as documentBrandingController from "../controllers/document-branding.controller.js";
export const documentBrandingRouter = express.Router();
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);

View file

@ -24,3 +24,44 @@ export const distributionBoardSupplyTypeLabels: Record<
MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik",
SiBe: "SiBe – Sicherheitsbeleuchtung",
};
/**
* Planner-owned descriptive distribution-board data shown on the board info
* card and in the print view. The values are local planning values. A later
* Revit/CSV round-trip may prefill them from mapped model parameters, but only
* through a previewed, explicitly confirmed project command (see
* docs/spec/revit-csv-integration-requirements.md); imports never overwrite
* them silently.
*
* `fedFrom` is intentionally free text for now. How a board is logically
* linked to its upstream board or feeding circuit is not decided yet.
*/
export const distributionBoardInfoFields = [
"boardNumber",
"location",
"fedFrom",
"remark",
] as const;
export type DistributionBoardInfoField =
(typeof distributionBoardInfoFields)[number];
export const distributionBoardInfoFieldLabels: Record<
DistributionBoardInfoField,
string
> = {
boardNumber: "Verteilernummer",
location: "Örtlichkeit",
fedFrom: "Gespeist von",
remark: "Bemerkung",
};
export const distributionBoardInfoFieldMaxLengths: Record<
DistributionBoardInfoField,
number
> = {
boardNumber: 50,
location: 200,
fedFrom: 200,
remark: 2000,
};

View file

@ -0,0 +1,25 @@
import { z } from "zod";
export const documentLogoMimeTypes = [
"image/png",
"image/jpeg",
"image/svg+xml",
] as const;
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
export const maxDocumentLogoBytes = 2 * 1024 * 1024;
export const uploadDocumentLogoSchema = z
.object({
fileName: z.string().trim().min(1).max(200),
mimeType: z.enum(documentLogoMimeTypes),
contentBase64: z
.string()
.min(1)
// Base64 expands by 4/3; the decoded size is checked again server-side.
.max(Math.ceil((maxDocumentLogoBytes * 4) / 3) + 4),
})
.strict();
export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>;

View file

@ -1,5 +1,9 @@
import { z } from "zod";
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../constants/distribution-board.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectSchema = z.object({
@ -42,12 +46,27 @@ export const createDistributionBoardSchema = z
})
.strict();
// Empty info text is stored as null so that "not entered" has one
// representation in the database, snapshots and print view.
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
z
.string()
.trim()
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const updateDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
simultaneityFactor: z.number().finite().min(0).max(1),
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
})
.strict();