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

@ -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(),
};