forked from jappel/leistungsbilanz-ts
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:
parent
2742b26b87
commit
94688d7a8c
38 changed files with 5301 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
261
src/app/projects/[projectId]/print/page.tsx
Normal file
261
src/app/projects/[projectId]/print/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/db/migrations/0008_colossal_terrax.sql
Normal file
12
src/db/migrations/0008_colossal_terrax.sql
Normal 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;
|
||||
2596
src/db/migrations/meta/0008_snapshot.json
Normal file
2596
src/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,13 @@
|
|||
"when": 1786114831610,
|
||||
"tag": "0007_watery_kingpin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1790356758671,
|
||||
"tag": "0008_colossal_terrax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
52
src/db/repositories/document-branding.repository.ts
Normal file
52
src/db/repositories/document-branding.repository.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
});
|
||||
|
||||
|
|
|
|||
18
src/db/schema/document-branding-assets.ts
Normal file
18
src/db/schema/document-branding-assets.ts
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -521,11 +566,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 +593,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
}
|
||||
|
|
@ -541,6 +604,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...legacy,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
legacy.distributionBoards
|
||||
),
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
|
|
@ -551,6 +617,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
...baseline,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...baseline.project, isPublicBuilding: false },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
baseline.distributionBoards
|
||||
),
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
180
src/frontend/print/print-document-model.ts
Normal file
180
src/frontend/print/print-document-model.ts
Normal 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;
|
||||
}
|
||||
473
src/frontend/print/print-document.css
Normal file
473
src/frontend/print/print-document.css
Normal 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;
|
||||
}
|
||||
}
|
||||
612
src/frontend/print/print-document.tsx
Normal file
612
src/frontend/print/print-document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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 { CableSizingCalculationRepository } from "../../db/repositories/cable-sizing-calculation.repository.js";
|
||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||
|
||||
export const circuitDeviceRowRepository =
|
||||
new CircuitDeviceRowRepository(db);
|
||||
|
|
@ -35,3 +36,4 @@ export const externalCsvConfigurationRepository =
|
|||
new ExternalCsvConfigurationRepository(db);
|
||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);
|
||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
|||
84
src/server/controllers/document-branding.controller.ts
Normal file
84
src/server/controllers/document-branding.controller.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import express from "express";
|
|||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { cableSizingRouter } from "./routes/cable-sizing.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";
|
||||
|
|
@ -53,6 +54,7 @@ app.use("/api/projects", projectRouter);
|
|||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
app.use("/api/cable-sizing", cableSizingRouter);
|
||||
app.use("/api/document-branding", documentBrandingRouter);
|
||||
|
||||
app.use(errorMiddleware);
|
||||
|
||||
|
|
|
|||
9
src/server/routes/document-branding.routes.ts
Normal file
9
src/server/routes/document-branding.routes.ts
Normal 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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
25
src/shared/validation/document-branding.schemas.ts
Normal file
25
src/shared/validation/document-branding.schemas.ts
Normal 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>;
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue