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
2e3e85a8e5
commit
4779e4a1a7
40 changed files with 5297 additions and 33 deletions
|
|
@ -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();
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue