Add versioned project settings modal

This commit is contained in:
2026-07-28 18:16:22 +02:00
parent e1fc81a083
commit d77cfbeb49
22 changed files with 2563 additions and 111 deletions
+10 -6
View File
@@ -209,12 +209,16 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert die beiden Projekt-Standardspannungen als
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und
besitzt keinen separaten direkten Settings-Schreibweg mehr.
`project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung sowie beide Standardspannungen als eine
atomare Änderung. Die Projektseite bearbeitet diese Angaben in einem
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
+18
View File
@@ -48,12 +48,25 @@ const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
);
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
const requiredProjectColumns = [
"internal_project_number",
"external_project_number",
"building_owner",
"description",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
);
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log("Required project columns:", requiredProjectColumns.join(", "));
if (missing.length > 0) {
console.error("Missing tables:", missing.join(", "));
@@ -78,4 +91,9 @@ if (missingCircuitColumns.length > 0) {
process.exit(1);
}
if (missingProjectColumns.length > 0) {
console.error("Missing project columns:", missingProjectColumns.join(", "));
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
+34 -61
View File
@@ -40,6 +40,10 @@ import {
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
ProjectSettingsModal,
type ProjectSettingsInput,
} from "../../../frontend/components/project-settings-modal";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@@ -92,8 +96,7 @@ export default function ProjectDetailPage() {
const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState("");
const [roomFloorId, setRoomFloorId] = useState("");
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState("230");
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState("400");
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({
name: "",
displayName: "",
@@ -142,10 +145,6 @@ export default function ProjectDetailPage() {
]) => {
const currentProject = projects.find((item) => item.id === projectId) ?? null;
setProject(currentProject);
if (currentProject) {
setSinglePhaseVoltageV(String(currentProject.singlePhaseVoltageV));
setThreePhaseVoltageV(String(currentProject.threePhaseVoltageV));
}
setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists);
setFloors(loadedFloors);
@@ -259,7 +258,7 @@ export default function ProjectDetailPage() {
}
}
async function handleSaveProjectSettings() {
async function handleSaveProjectSettings(input: ProjectSettingsInput) {
if (!projectId || !project) {
return;
}
@@ -269,12 +268,10 @@ export default function ProjectDetailPage() {
const result = await updateProjectSettings(
projectId,
project.currentRevision,
{
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
}
input
);
setProject(result.project);
setIsProjectSettingsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
} finally {
@@ -532,66 +529,34 @@ export default function ProjectDetailPage() {
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div>
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
<p className="text-secondary mb-0">Verteilerübersicht und Einstieg in die Stromkreislisten</p>
<p className="text-secondary mb-0">
{project?.internalProjectNumber
? `Projektnummer ${project.internalProjectNumber} · `
: ""}
Verteilerübersicht und Einstieg in die Stromkreislisten
</p>
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary"
disabled={!project}
onClick={() => setIsProjectSettingsOpen(true)}
type="button"
>
Projekteinstellungen
</button>
<Link className="btn btn-outline-secondary" href="/projects">
Zur Projektseite
Zur Projektübersicht
</Link>
</div>
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="row g-4">
<section className="col-12">
<div className="card shadow-sm">
<div className="card-header">Projekteigenschaften</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-4">
<label className="form-label">Standardspannung 1-phasig [V]</label>
<input
className="form-control"
type="number"
min="1"
value={singlePhaseVoltageV}
onChange={(event) => setSinglePhaseVoltageV(event.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Standardspannung 3-phasig [V]</label>
<input
className="form-control"
type="number"
min="1"
value={threePhaseVoltageV}
onChange={(event) => setThreePhaseVoltageV(event.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<button
className="btn btn-primary w-100"
type="button"
onClick={handleSaveProjectSettings}
disabled={
isSaving ||
!project ||
(Number(singlePhaseVoltageV) ===
project.singlePhaseVoltageV &&
Number(threePhaseVoltageV) ===
project.threePhaseVoltageV)
}
>
Projekteigenschaften speichern
</button>
</div>
</div>
</div>
</div>
</section>
{project ? (
<section className="col-12">
<ProjectVersionHistory
@@ -1156,6 +1121,14 @@ export default function ProjectDetailPage() {
) : null}
</section>
{project && isProjectSettingsOpen ? (
<ProjectSettingsModal
isSaving={isSaving}
onClose={() => setIsProjectSettingsOpen(false)}
onSave={handleSaveProjectSettings}
project={project}
/>
) : null}
</main>
);
}
@@ -0,0 +1,4 @@
ALTER TABLE `projects` ADD `internal_project_number` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `external_project_number` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `building_owner` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `description` text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -113,6 +113,13 @@
"when": 1785014383032,
"tag": "0015_modern_madame_masque",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1785254079435,
"tag": "0016_dashing_darkstar",
"breakpoints": true
}
]
}
@@ -2,6 +2,8 @@ import { eq } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
createProjectSettingsUpdateProjectCommand,
legacyProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js";
import type {
ExecuteProjectSettingsUpdateCommandInput,
@@ -37,22 +39,45 @@ export class ProjectSettingsProjectCommandRepository
if (!current) {
throw new Error("Project not found.");
}
if (
current.singlePhaseVoltageV ===
input.command.payload.singlePhaseVoltageV &&
current.threePhaseVoltageV ===
input.command.payload.threePhaseVoltageV
) {
const target: ProjectSettingsValues =
input.command.schemaVersion ===
legacyProjectSettingsUpdateCommandSchemaVersion
? {
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
...input.command.payload,
}
: input.command.payload;
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
const inverse = createProjectSettingsUpdateProjectCommand({
const inverse =
input.command.schemaVersion ===
legacyProjectSettingsUpdateCommandSchemaVersion
? {
schemaVersion: legacyProjectSettingsUpdateCommandSchemaVersion,
type: input.command.type,
payload: {
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
});
const updated = tx
.update(projects)
.set(input.command.payload)
.set(target)
.where(eq(projects.id, input.projectId))
.run();
if (updated.changes !== 1) {
@@ -62,3 +87,18 @@ export class ProjectSettingsProjectCommandRepository
return inverse;
}
}
function projectSettingsEqual(
current: ProjectSettingsValues,
target: ProjectSettingsValues
) {
return (
current.name === target.name &&
current.internalProjectNumber === target.internalProjectNumber &&
current.externalProjectNumber === target.externalProjectNumber &&
current.buildingOwner === target.buildingOwner &&
current.description === target.description &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
);
}
@@ -5,6 +5,7 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
deserializeProjectStateSnapshot,
legacyProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@@ -138,7 +139,10 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
if (
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
@@ -5,6 +5,7 @@ import {
createProjectStateRestoreCommand,
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
serializeProjectStateSnapshot,
type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
@@ -38,7 +39,10 @@ export class ProjectStateRestoreCommandRepository
execute(input: ExecuteProjectStateRestoreCommandInput) {
assertProjectStateRestoreCommand(input.command);
if (input.command.payload.targetState.project.id !== input.projectId) {
const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
if (targetState.project.id !== input.projectId) {
throw new Error("Restore state belongs to a different project.");
}
@@ -68,15 +72,16 @@ export class ProjectStateRestoreCommandRepository
);
}
const targetPayloadJson = serializeProjectStateSnapshot(
const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
const targetPayloadJson = serializeProjectStateSnapshot(targetState);
const inverse = createProjectStateRestoreCommand(
hashProjectStatePayload(targetPayloadJson),
current.state
);
replaceProjectState(tx, input.command.payload.targetState);
replaceProjectState(tx, targetState);
return inverse;
}
@@ -111,6 +116,10 @@ function replaceProjectState(
.update(projects)
.set({
name: state.project.name,
internalProjectNumber: state.project.internalProjectNumber,
externalProjectNumber: state.project.externalProjectNumber,
buildingOwner: state.project.buildingOwner,
description: state.project.description,
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV,
})
@@ -32,6 +32,10 @@ export function readProjectStateSnapshot(
.select({
id: projects.id,
name: projects.name,
internalProjectNumber: projects.internalProjectNumber,
externalProjectNumber: projects.externalProjectNumber,
buildingOwner: projects.buildingOwner,
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
@@ -172,6 +176,10 @@ export function readProjectStateSnapshot(
project: {
id: project.id,
name: project.name,
internalProjectNumber: project.internalProjectNumber,
externalProjectNumber: project.externalProjectNumber,
buildingOwner: project.buildingOwner,
description: project.description,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
},
@@ -20,6 +20,10 @@ export class ProjectRepository {
const project = {
id,
name: input.name,
internalProjectNumber: input.internalProjectNumber ?? null,
externalProjectNumber: input.externalProjectNumber ?? null,
buildingOwner: input.buildingOwner ?? null,
description: input.description ?? null,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
currentRevision: 0,
+4
View File
@@ -3,6 +3,10 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
internalProjectNumber: text("internal_project_number"),
externalProjectNumber: text("external_project_number"),
buildingOwner: text("building_owner"),
description: text("description"),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
currentRevision: integer("current_revision").notNull().default(0),
@@ -2,23 +2,42 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectSettingsUpdateCommandType =
"project.update-settings" as const;
export const projectSettingsUpdateCommandSchemaVersion = 1 as const;
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
export interface ProjectSettingsValues {
export interface LegacyProjectSettingsValues {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
export interface ProjectSettingsUpdateProjectCommand
export interface ProjectSettingsValues extends LegacyProjectSettingsValues {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
}
export interface LegacyProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export interface CurrentProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<ProjectSettingsValues> {
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export type ProjectSettingsUpdateProjectCommand =
| LegacyProjectSettingsUpdateProjectCommand
| CurrentProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand(
values: ProjectSettingsValues
): ProjectSettingsUpdateProjectCommand {
const command: ProjectSettingsUpdateProjectCommand = {
): CurrentProjectSettingsUpdateProjectCommand {
const command: CurrentProjectSettingsUpdateProjectCommand = {
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
type: projectSettingsUpdateCommandType,
payload: { ...values },
@@ -31,16 +50,45 @@ export function assertProjectSettingsUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectSettingsUpdateProjectCommand {
if (
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion ||
command.type !== projectSettingsUpdateCommandType
command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !==
legacyProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
) {
throw new Error("Unsupported project settings update command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Project settings update command contains invalid values."
);
}
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
assertLegacyValues(command.payload);
return;
}
if (
!isPlainObject(command.payload) ||
!isTrimmedString(command.payload.name, 1, 200) ||
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
!isNullableTrimmedString(command.payload.externalProjectNumber, 100) ||
!isNullableTrimmedString(command.payload.buildingOwner, 200) ||
!isNullableTrimmedString(command.payload.description, 2000) ||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
Object.keys(command.payload).length !== 2
Object.keys(command.payload).length !== 7
) {
throw new Error(
"Project settings update command contains invalid values."
);
}
}
function assertLegacyValues(
payload: Record<string, unknown>
): asserts payload is Record<string, unknown> & LegacyProjectSettingsValues {
if (
!isPositiveFiniteNumber(payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(payload.threePhaseVoltageV) ||
Object.keys(payload).length !== 2
) {
throw new Error(
"Project settings update command contains invalid voltages."
@@ -59,3 +107,26 @@ function isPositiveFiniteNumber(value: unknown): value is number {
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isTrimmedString(
value: unknown,
minimumLength: number,
maximumLength: number
): value is string {
return (
typeof value === "string" &&
value === value.trim() &&
value.length >= minimumLength &&
value.length <= maximumLength
);
}
function isNullableTrimmedString(
value: unknown,
maximumLength: number
): value is string | null {
return (
value === null ||
(isTrimmedString(value, 1, maximumLength))
);
}
@@ -1,12 +1,13 @@
import { z } from "zod";
export const projectStateSnapshotSchemaVersion = 1 as const;
export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
export const projectStateSnapshotSchemaVersion = 2 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite();
const projectSchema = z
const legacyProjectSchema = z
.object({
id: idSchema,
name: z.string().trim().min(1),
@@ -15,6 +16,13 @@ const projectSchema = z
})
.strict();
const projectSchema = legacyProjectSchema.extend({
internalProjectNumber: z.string().trim().min(1).max(100).nullable(),
externalProjectNumber: z.string().trim().min(1).max(100).nullable(),
buildingOwner: z.string().trim().min(1).max(200).nullable(),
description: z.string().trim().min(1).max(2000).nullable(),
});
const distributionBoardSchema = z
.object({
id: idSchema,
@@ -132,10 +140,7 @@ const roomSchema = z
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
const projectStateSnapshotContents = {
distributionBoards: z.array(distributionBoardSchema),
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
@@ -143,6 +148,21 @@ export const projectStateSnapshotSchema = z
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
};
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: legacyProjectSchema,
...projectStateSnapshotContents,
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
...projectStateSnapshotContents,
})
.strict();
@@ -153,11 +173,37 @@ export type ProjectStateSnapshot = z.infer<
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
const snapshot = projectStateSnapshotSchema.parse(value);
const version = isPlainObject(value) ? value.schemaVersion : undefined;
const snapshot =
version === legacyProjectStateSnapshotSchemaVersion
? upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
: projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
}
function upgradeLegacyProjectStateSnapshot(
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
...snapshot.project,
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
},
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function serializeProjectStateSnapshot(
snapshot: ProjectStateSnapshot
): string {
@@ -0,0 +1,225 @@
"use client";
import { FormEvent, useState } from "react";
import type { ProjectDto } from "../types";
export interface ProjectSettingsInput {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
interface ProjectSettingsModalProps {
isSaving: boolean;
onClose: () => void;
onSave: (input: ProjectSettingsInput) => Promise<void>;
project: ProjectDto;
}
export function ProjectSettingsModal({
isSaving,
onClose,
onSave,
project,
}: ProjectSettingsModalProps) {
const [name, setName] = useState(project.name);
const [internalProjectNumber, setInternalProjectNumber] = useState(
project.internalProjectNumber ?? ""
);
const [externalProjectNumber, setExternalProjectNumber] = useState(
project.externalProjectNumber ?? ""
);
const [buildingOwner, setBuildingOwner] = useState(
project.buildingOwner ?? ""
);
const [description, setDescription] = useState(
project.description ?? ""
);
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState(
String(project.singlePhaseVoltageV)
);
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
String(project.threePhaseVoltageV)
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
name: name.trim(),
internalProjectNumber: toNullableString(internalProjectNumber),
externalProjectNumber: toNullableString(externalProjectNumber),
buildingOwner: toNullableString(buildingOwner),
description: toNullableString(description),
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
});
}
const isValid =
name.trim().length > 0 &&
Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0;
return (
<>
<div
aria-labelledby="project-settings-title"
aria-modal="true"
className="modal fade show d-block"
role="dialog"
tabIndex={-1}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<form className="modal-content" onSubmit={handleSubmit}>
<div className="modal-header">
<div>
<h2 className="modal-title fs-5" id="project-settings-title">
Projekteinstellungen
</h2>
<p className="text-secondary small mb-0">
Stammdaten und elektrische Standardwerte des Projekts
</p>
</div>
<button
aria-label="Schließen"
className="btn-close"
disabled={isSaving}
onClick={onClose}
type="button"
/>
</div>
<div className="modal-body">
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="project-name">
Projektname
</label>
<input
autoFocus
className="form-control"
id="project-name"
maxLength={200}
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="internal-project-number">
Projektnummer intern
</label>
<input
className="form-control"
id="internal-project-number"
maxLength={100}
onChange={(event) =>
setInternalProjectNumber(event.target.value)
}
value={internalProjectNumber}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="external-project-number">
Projektnummer extern
</label>
<input
className="form-control"
id="external-project-number"
maxLength={100}
onChange={(event) =>
setExternalProjectNumber(event.target.value)
}
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="building-owner">
Bauherr
</label>
<input
className="form-control"
id="building-owner"
maxLength={200}
onChange={(event) => setBuildingOwner(event.target.value)}
value={buildingOwner}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="project-description">
Beschreibung
</label>
<textarea
className="form-control"
id="project-description"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
rows={4}
value={description}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="single-phase-voltage">
Standardspannung 1-phasig [V]
</label>
<input
className="form-control"
id="single-phase-voltage"
min="1"
onChange={(event) =>
setSinglePhaseVoltageV(event.target.value)
}
required
type="number"
value={singlePhaseVoltageV}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="three-phase-voltage">
Standardspannung 3-phasig [V]
</label>
<input
className="form-control"
id="three-phase-voltage"
min="1"
onChange={(event) =>
setThreePhaseVoltageV(event.target.value)
}
required
type="number"
value={threePhaseVoltageV}
/>
</div>
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-outline-secondary"
disabled={isSaving}
onClick={onClose}
type="button"
>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={isSaving || !isValid}
type="submit"
>
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}
function toNullableString(value: string) {
return value.trim() || null;
}
+4
View File
@@ -1,6 +1,10 @@
export interface ProjectDto {
id: string;
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
currentRevision: number;
+9 -1
View File
@@ -210,7 +210,15 @@ export function createProject(name: string) {
export function updateProjectSettings(
projectId: string,
expectedRevision: number,
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
input: {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
buildingOwner: string | null;
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
) {
return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`,
@@ -2,7 +2,11 @@ import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectSchema = z.object({
name: z.string().min(1),
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(),
singlePhaseVoltageV: z.number().positive().optional(),
threePhaseVoltageV: z.number().positive().optional(),
});
@@ -10,6 +14,11 @@ export const createProjectSchema = z.object({
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(),
singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
})
+5
View File
@@ -951,6 +951,11 @@ describe("project command service", () => {
projectId: "project-1",
expectedRevision: 0,
command: createProjectSettingsUpdateProjectCommand({
name: "Test project",
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -25,6 +25,7 @@ function createTestDatabase(): DatabaseContext {
.values({
id: "project-1",
name: "Testprojekt",
internalProjectNumber: "INT-1",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
})
@@ -35,6 +36,11 @@ function createTestDatabase(): DatabaseContext {
function getSettings(context: DatabaseContext) {
return context.db
.select({
name: projects.name,
internalProjectNumber: projects.internalProjectNumber,
externalProjectNumber: projects.externalProjectNumber,
buildingOwner: projects.buildingOwner,
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
@@ -44,15 +50,32 @@ function getSettings(context: DatabaseContext) {
.get();
}
function settings(
overrides: Partial<Parameters<
typeof createProjectSettingsUpdateProjectCommand
>[0]> = {}
) {
return {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
...overrides,
};
}
describe("project settings project-command repository", () => {
it("validates settings commands and sends the expected revision from the frontend", async () => {
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 0,
threePhaseVoltageV: 400,
}),
/invalid voltages/
/invalid values/
);
const requests: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
@@ -68,6 +91,7 @@ describe("project settings project-command repository", () => {
};
try {
await updateProjectSettings("project-1", 7, {
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
});
@@ -79,6 +103,11 @@ describe("project settings project-command repository", () => {
url: "/api/projects/project-1",
body: {
expectedRevision: 7,
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
},
@@ -94,6 +123,9 @@ describe("project settings project-command repository", () => {
);
const history = new ProjectHistoryRepository(context.db);
const command = createProjectSettingsUpdateProjectCommand({
...settings(),
name: "Neuer Projektname",
buildingOwner: "Beispiel Bau GmbH",
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
});
@@ -104,11 +136,21 @@ describe("project settings project-command repository", () => {
command,
});
assert.deepEqual(getSettings(context), {
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
currentRevision: 1,
});
assert.deepEqual(forward.inverse.payload, {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
});
@@ -123,6 +165,11 @@ describe("project settings project-command repository", () => {
command: forward.inverse,
});
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 2,
@@ -138,6 +185,11 @@ describe("project settings project-command repository", () => {
command,
});
assert.deepEqual(getSettings(context), {
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
currentRevision: 3,
@@ -147,6 +199,37 @@ describe("project settings project-command repository", () => {
}
});
it("applies persisted version-one voltage commands without clearing metadata", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: {
schemaVersion: 1,
type: "project.update-settings",
payload: {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
},
},
});
assert.deepEqual(getSettings(context), {
...settings({
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
currentRevision: 1,
});
} finally {
context.close();
}
});
it("rejects no-op, invalid project and stale revision without partial writes", () => {
const context = createTestDatabase();
try {
@@ -160,8 +243,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
...settings(),
}),
}),
/did not change/
@@ -173,6 +255,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -186,6 +269,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 1,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -193,6 +277,11 @@ describe("project settings project-command repository", () => {
ProjectRevisionConflictError
);
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 0,
@@ -226,6 +315,7 @@ describe("project settings project-command repository", () => {
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
@@ -233,6 +323,11 @@ describe("project settings project-command repository", () => {
/forced project settings history failure/
);
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
currentRevision: 0,
+38 -1
View File
@@ -20,6 +20,7 @@ import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
@@ -133,7 +134,7 @@ describe("project snapshot repository", () => {
});
assert.ok(created);
assert.equal(created.sourceRevision, 0);
assert.equal(created.schemaVersion, 1);
assert.equal(created.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(created.name, "Vor Ausschreibung");
assert.equal(created.description, "Sicherer Stand");
assert.equal(created.createdByActorId, "planner-1");
@@ -262,6 +263,42 @@ describe("project snapshot repository", () => {
prepared.command.payload.targetState.project.id,
"project-1"
);
const currentPayload = JSON.parse(
context.db
.select({ payloadJson: projectSnapshots.payloadJson })
.from(projectSnapshots)
.where(eq(projectSnapshots.id, snapshot.id))
.get()!.payloadJson
) as Record<string, unknown> & {
project: Record<string, unknown>;
};
currentPayload.schemaVersion = 1;
delete currentPayload.project.internalProjectNumber;
delete currentPayload.project.externalProjectNumber;
delete currentPayload.project.buildingOwner;
delete currentPayload.project.description;
const legacyPayloadJson = JSON.stringify(currentPayload);
context.db
.update(projectSnapshots)
.set({
schemaVersion: 1,
payloadJson: legacyPayloadJson,
payloadSha256: crypto
.createHash("sha256")
.update(legacyPayloadJson)
.digest("hex"),
})
.where(eq(projectSnapshots.id, snapshot.id))
.run();
const legacyPrepared = repository.prepareRestore(
"project-1",
snapshot.id
);
assert.ok(legacyPrepared);
assert.equal(
legacyPrepared.command.payload.targetState.project.buildingOwner,
null
);
context.db
.update(projectSnapshots)
.set({ payloadJson: `${JSON.stringify({})}\n` })
+28 -1
View File
@@ -3,16 +3,21 @@ import { describe, it } from "node:test";
import {
deserializeProjectStateSnapshot,
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
function minimalSnapshot() {
return {
schemaVersion: 1 as const,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: "project-1",
name: "Projekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
@@ -37,6 +42,28 @@ describe("project state snapshot model", () => {
);
});
it("upgrades version-one snapshots with empty project metadata", () => {
const legacy = {
...minimalSnapshot(),
schemaVersion: 1 as const,
project: {
id: "project-1",
name: "Altprojekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
};
const snapshot = parseProjectStateSnapshot(legacy);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(snapshot.project, {
...legacy.project,
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
});
});
it("rejects duplicate ids and cross-project ownership", () => {
assert.throws(
() =>