leistungsbilanz-ts/src/shared/validation/project-structure.schemas.ts
Grovy311 cd89c4a499 Add buildings above floors and a building structure page
Projects with several buildings could not be structured: floors and rooms
hung directly off the project. Floors can now belong to a building
(migration 0009: table buildings, nullable floors.building_id). Rooms and
distribution boards keep their floor; their building follows from it.
Existing floors stay without a building.

Buildings use the location-structure command store with
project-building.insert/.update/.delete, inverses and persistent Undo/Redo;
deleting a building with assigned floors is rejected. Floor commands carry
buildingId since command version 2. Version 1 floor commands from existing
history predate buildings and are upgraded with buildingId = null before
they are executed and persisted, so forward and inverse keep one version.

Project snapshot schema 7 stores buildings and floor.buildingId; versions
1-6 are upgraded without buildings. Restore and portable transfer include
buildings, and the transfer remaps building ids.

Buildings, floors and rooms move from the project overview to their own
page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume)
that filter each other, and a sidebar entry. Floor selects in the board and
room dialogs are grouped by building. Unchanged dialogs close without a
command. Details in docs/building-structure.md; see issue #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:52:34 +02:00

156 lines
5.2 KiB
TypeScript

import { z } from "zod";
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../constants/project-cover.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
// Empty text is stored as null so "not entered" has one representation.
const optionalCoverValueSchema = (field: ProjectCoverField) =>
z
.string()
.trim()
.max(projectCoverFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const createProjectSchema = z.object({
name: z.string().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).optional(),
externalProjectNumber: z.string().trim().max(100).optional(),
buildingOwner: z.string().trim().max(200).optional(),
description: z.string().trim().max(2000).optional(),
isPublicBuilding: z.boolean().optional(),
singlePhaseVoltageV: z.number().positive().optional(),
threePhaseVoltageV: z.number().positive().optional(),
});
export const updateProjectSettingsSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).nullable(),
externalProjectNumber: z.string().trim().max(100).nullable(),
buildingOwner: z.string().trim().max(200).nullable(),
description: z.string().trim().max(2000).nullable(),
isPublicBuilding: z.boolean(),
singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
enabledDistributionBoardSupplyTypes: z
.array(z.enum(distributionBoardSupplyTypes))
.min(1)
.refine((values) => new Set(values).size === values.length, {
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
}),
// Print cover fields; omitted fields keep their current value.
siteAddress: optionalCoverValueSchema("siteAddress"),
workStage: optionalCoverValueSchema("workStage"),
projectLead: optionalCoverValueSchema("projectLead"),
projectEditor: optionalCoverValueSchema("projectEditor"),
})
.strict();
export const createDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
.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();
export const copyDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
})
.strict();
export const deleteDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
})
.strict();
export const createBuildingSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
})
.strict();
export const updateBuildingSchema = createBuildingSchema;
// A missing buildingId keeps the current value (older clients send only the
// name); null removes the floor from its building.
export const createFloorSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
buildingId: z.string().trim().min(1).nullable().optional(),
})
.strict();
export const updateFloorSchema = createFloorSchema;
export const deleteProjectLocationSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
})
.strict();
export const createRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).optional(),
roomNumber: z.string().trim().min(1).max(50),
roomName: z.string().trim().min(1).max(200),
})
.strict();
export const updateRoomSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
roomNumber: z.string().trim().min(1).max(50),
roomName: z.string().trim().min(1).max(200),
})
.strict();
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer<
typeof updateProjectSettingsSchema
>;
export type CreateDistributionBoardInput = z.infer<
typeof createDistributionBoardSchema
>;