Compare commits

..

No commits in common. "a99980c47b08269e13a798eecf72072d092e6ba8" and "ea3c02cd6bdce7650ff1978e94740044cad72286" have entirely different histories.

55 changed files with 551 additions and 3335 deletions

View file

@ -13,7 +13,6 @@ services:
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true init: true
restart: unless-stopped
logging: logging:
driver: json-file driver: json-file
options: options:
@ -54,7 +53,6 @@ services:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true init: true
restart: unless-stopped
logging: logging:
driver: json-file driver: json-file
options: options:

View file

@ -359,9 +359,8 @@ Response sketch:
### Circuit Structure ### Circuit Structure
- `GET /projects/:projectId/circuit-sections/:sectionId/next-identifier` - `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`) - preview next identifier for section (`prefix + maxSuffix + 1`)
- returns 404 if the section does not belong to the given project
Circuit and device-row field updates, standalone insertions/deletions, single Circuit and device-row field updates, standalone insertions/deletions, single
or bulk device-row moves, circuit reorders and explicit renumbering are or bulk device-row moves, circuit reorders and explicit renumbering are

View file

@ -476,28 +476,16 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
und Verteilerkomponenten. Separate und Verteilerkomponenten. Separate
1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren 1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren
flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt. flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt.
Ein triggergeführtes Register erzwingt eine normalisierte, Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Der DB-Index normalisiert dabei nur über SQLites Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
eingebautes `lower()` (rein ASCII), erkennt also z.B. `"Ä1"` und `"ä1"` nicht Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für
als denselben Wert. Die gemeinsame Prüfung veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
`src/db/repositories/equipment-identifier-uniqueness.persistence.ts` Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
schließt diese Lücke: Sie normalisiert mit JavaScripts Unicode-fähigem zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten -sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
Gruppen einschließlich befüllter Unterbäume sowie vollständige
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
an und bearbeitet veränderliche Gruppen- und Fußkomponenten über dedizierte
Command-Modale. Gruppenanlage, -umbenennung, -sortierung, explizite
Neunummerierung, Same-Category-Stromkreiswechsel, geschütztes
Unterbaumlöschen und Stromkreisschutz sind integriert.
Migration `0006` ergänzt additiv Indizes auf `circuits.section_id` und
`circuit_device_rows.circuit_id`, den beiden am häufigsten gefilterten
Fremdschlüsselspalten sowie den Cascade-Delete-Pfaden von Abschnitten und
Stromkreisen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen

View file

@ -58,15 +58,6 @@ mit Laufzeit und Speicherverbrauch nützlich, um Speicherlecks oder Hänger
einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche
`LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder `LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder
API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar. API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar.
Ein `close`-Ereignis ohne vorheriges `finish` wird als `request aborted before
response finished` (`warn`) geloggt und zeigt damit vom Client oder einem
vorgeschalteten Proxy abgebrochene Verbindungen.
Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet
den jeweiligen Prozess anschließend bewusst (`process.exit(1)`), statt in
einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
## Voraussetzungen für ein späteres Produktionssetup ## Voraussetzungen für ein späteres Produktionssetup

View file

@ -17,13 +17,13 @@ import {
deleteDistributionBoard, deleteDistributionBoard,
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
exportProjectTransfer, exportProjectTransfer,
getProject,
getProjectDeviceSyncPreview, getProjectDeviceSyncPreview,
listCircuitLists, listCircuitLists,
listDistributionBoards, listDistributionBoards,
listFloors, listFloors,
listGlobalDevices, listGlobalDevices,
listProjectDevices, listProjectDevices,
listProjects,
listRooms, listRooms,
importProjectTransfer, importProjectTransfer,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
@ -129,7 +129,7 @@ export default function ProjectDetailPage() {
return; return;
} }
Promise.all([ Promise.all([
getProject(projectId), listProjects(),
listDistributionBoards(projectId), listDistributionBoards(projectId),
listCircuitLists(projectId), listCircuitLists(projectId),
listFloors(projectId), listFloors(projectId),
@ -138,7 +138,7 @@ export default function ProjectDetailPage() {
listGlobalDevices(), listGlobalDevices(),
]) ])
.then(([ .then(([
currentProject, projects,
distributionBoards, distributionBoards,
loadedCircuitLists, loadedCircuitLists,
loadedFloors, loadedFloors,
@ -146,6 +146,7 @@ export default function ProjectDetailPage() {
loadedProjectDevices, loadedProjectDevices,
loadedGlobalDevices, loadedGlobalDevices,
]) => { ]) => {
const currentProject = projects.find((item) => item.id === projectId) ?? null;
setProject(currentProject); setProject(currentProject);
setBoards(distributionBoards); setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists); setCircuitLists(loadedCircuitLists);

View file

@ -1,2 +0,0 @@
CREATE INDEX `circuit_device_rows_circuit_id_idx` ON `circuit_device_rows` (`circuit_id`);--> statement-breakpoint
CREATE INDEX `circuits_section_id_idx` ON `circuits` (`section_id`);

File diff suppressed because it is too large Load diff

View file

@ -43,13 +43,6 @@
"when": 1785687503453, "when": 1785687503453,
"tag": "0005_stale_gorilla_man", "tag": "0005_stale_gorilla_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
} }
] ]
} }

View file

@ -43,6 +43,12 @@ export interface CircuitDeviceRowPatchInput {
overriddenFields?: string | null; overriddenFields?: string | null;
} }
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
}
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) { export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
return { return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
@ -99,3 +105,15 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
return values; return values;
} }
export function toCircuitDeviceRowCreateValues(
id: string,
input: CircuitDeviceRowCreateInput
) {
return {
id,
circuitId: input.circuitId,
sortOrder: input.sortOrder,
...toCircuitDeviceRowUpdateValues(input),
};
}

View file

@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
import { import {
assertCircuitUpdateProjectCommand, assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand,
@ -21,7 +21,6 @@ import {
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
type CircuitRow = typeof circuits.$inferSelect; type CircuitRow = typeof circuits.$inferSelect;
@ -167,12 +166,20 @@ export class CircuitProjectCommandRepository
) { ) {
return; return;
} }
assertEquipmentIdentifierAvailable( const duplicate = database
database, .select({ id: circuits.id })
circuit.circuitListId, .from(circuits)
equipmentIdentifier, .where(
circuit.id and(
); eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, circuit.id)
)
)
.get();
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
} }
} }

View file

@ -27,7 +27,6 @@ import {
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class CircuitStructureProjectCommandRepository export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore implements CircuitStructureProjectCommandStore
@ -104,11 +103,24 @@ export class CircuitStructureProjectCommandRepository
if (existingCircuit) { if (existingCircuit) {
throw new Error("Circuit id already exists."); throw new Error("Circuit id already exists.");
} }
assertEquipmentIdentifierAvailable( const duplicateIdentifier = database
database, .select({ id: circuits.id })
snapshot.circuitListId, .from(circuits)
snapshot.equipmentIdentifier .where(
); and(
eq(circuits.circuitListId, snapshot.circuitListId),
eq(
circuits.equipmentIdentifier,
snapshot.equipmentIdentifier
)
)
)
.get();
if (duplicateIdentifier) {
throw new Error(
"Duplicate equipmentIdentifier in circuit list."
);
}
if (snapshot.deviceRows.length > 0) { if (snapshot.deviceRows.length > 0) {
const rowIds = snapshot.deviceRows.map((row) => row.id); const rowIds = snapshot.deviceRows.map((row) => row.id);

View file

@ -22,7 +22,6 @@ import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore implements DistributionBoardComponentStructureProjectCommandStore
@ -95,11 +94,6 @@ export class DistributionBoardComponentStructureProjectCommandRepository
if (existing) { if (existing) {
throw new Error("Distribution-board component id already exists."); throw new Error("Distribution-board component id already exists.");
} }
assertEquipmentIdentifierAvailable(
database,
snapshot.component.circuitListId,
snapshot.component.equipmentIdentifier
);
database database
.insert(distributionBoardComponents) .insert(distributionBoardComponents)
.values(snapshot.component) .values(snapshot.component)
@ -193,17 +187,6 @@ export class DistributionBoardComponentStructureProjectCommandRepository
"Distribution-board component changed before update." "Distribution-board component changed before update."
); );
} }
if (
target.component.equipmentIdentifier !==
expected.component.equipmentIdentifier
) {
assertEquipmentIdentifierAvailable(
database,
expected.component.circuitListId,
target.component.equipmentIdentifier,
expected.component.id
);
}
database database
.update(distributionBoardComponents) .update(distributionBoardComponents)
.set(target.component) .set(target.component)

View file

@ -1,41 +0,0 @@
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuitListEquipmentIdentifiers } from "../schema/circuit-list-equipment-identifiers.js";
export function normalizeEquipmentIdentifier(value: string): string {
return value.trim().toLowerCase();
}
/**
* The DB-level normalized unique index uses SQLite's built-in lower(),
* which only folds ASCII a-z and leaves German characters (Ä/Ö/Ü/ß/)
* untouched, so it alone would let e.g. "Ä1" and "ä1" coexist. This check
* normalizes with JS's Unicode-aware toLowerCase() against every
* identifier already registered for the circuit list (circuits and
* distribution-board components share one BMK namespace via
* circuit_list_equipment_identifiers), catching what the DB index cannot.
*/
export function assertEquipmentIdentifierAvailable(
database: AppDatabase,
circuitListId: string,
equipmentIdentifier: string,
excludeOwnerId?: string
): void {
const candidate = normalizeEquipmentIdentifier(equipmentIdentifier);
const existing = database
.select({
ownerId: circuitListEquipmentIdentifiers.ownerId,
equipmentIdentifier: circuitListEquipmentIdentifiers.equipmentIdentifier,
})
.from(circuitListEquipmentIdentifiers)
.where(eq(circuitListEquipmentIdentifiers.circuitListId, circuitListId))
.all();
const duplicate = existing.some(
(row) =>
row.ownerId !== excludeOwnerId &&
normalizeEquipmentIdentifier(row.equipmentIdentifier) === candidate
);
if (duplicate) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
}

View file

@ -212,9 +212,7 @@ function replaceExternalState(
if (target.roomMappings.length) { if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run(); database.insert(externalRoomMappings).values(target.roomMappings).run();
} }
if (target.objects.length) { database.insert(externalModelObjects).values(target.objects).run();
database.insert(externalModelObjects).values(target.objects).run();
}
} }
function decodeCanonicalBase64(value: string) { function decodeCanonicalBase64(value: string) {

View file

@ -32,7 +32,6 @@ import {
loadExpectedExternalObjectTransitions, loadExpectedExternalObjectTransitions,
snapshotsEqual, snapshotsEqual,
} from "./external-object-assignment.persistence.js"; } from "./external-object-assignment.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore implements ExternalObjectNewCircuitProjectCommandStore
@ -91,11 +90,12 @@ export class ExternalObjectNewCircuitProjectCommandRepository
.where(eq(circuits.id, circuit.id)).get()) { .where(eq(circuits.id, circuit.id)).get()) {
throw new Error("External circuit id already exists."); throw new Error("External circuit id already exists.");
} }
assertEquipmentIdentifierAvailable( if (database.select({ id: circuits.id }).from(circuits).where(and(
database, eq(circuits.circuitListId, circuit.circuitListId),
circuit.circuitListId, eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
circuit.equipmentIdentifier )).get()) {
); throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id)).get()) { .where(eq(circuitDeviceRows.id, row.id)).get()) {
throw new Error("External device-row id already exists."); throw new Error("External device-row id already exists.");

View file

@ -1,38 +1,35 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js"; import { circuits } from "./circuits.js";
import { projectDevices } from "./project-devices.js"; import { projectDevices } from "./project-devices.js";
import { rooms } from "./rooms.js"; import { rooms } from "./rooms.js";
export const circuitDeviceRows = sqliteTable( export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
"circuit_device_rows", id: text("id").primaryKey(),
{ circuitId: text("circuit_id")
id: text("id").primaryKey(), .notNull()
circuitId: text("circuit_id") .references(() => circuits.id, { onDelete: "cascade" }),
.notNull() linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
.references(() => circuits.id, { onDelete: "cascade" }), onDelete: "set null",
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { }),
onDelete: "set null", sortOrder: integer("sort_order").notNull().default(0),
}), name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0), displayName: text("display_name").notNull(),
name: text("name").notNull(), phaseType: text("phase_type"),
displayName: text("display_name").notNull(), connectionKind: text("connection_kind"),
phaseType: text("phase_type"), costGroup: text("cost_group"),
connectionKind: text("connection_kind"), category: text("category"),
costGroup: text("cost_group"), level: text("level"),
category: text("category"), roomId: text("room_id").references(() => rooms.id, {
level: text("level"), onDelete: "set null",
roomId: text("room_id").references(() => rooms.id, { }),
onDelete: "set null", roomNumberSnapshot: text("room_number_snapshot"),
}), roomNameSnapshot: text("room_name_snapshot"),
roomNumberSnapshot: text("room_number_snapshot"), quantity: integer("quantity").notNull(),
roomNameSnapshot: text("room_name_snapshot"), manualQuantity: integer("manual_quantity").notNull().default(0),
quantity: integer("quantity").notNull(), powerPerUnit: real("power_per_unit").notNull(),
manualQuantity: integer("manual_quantity").notNull().default(0), simultaneityFactor: real("simultaneity_factor").notNull(),
powerPerUnit: real("power_per_unit").notNull(), cosPhi: real("cos_phi"),
simultaneityFactor: real("simultaneity_factor").notNull(), remark: text("remark"),
cosPhi: real("cos_phi"), overriddenFields: text("overridden_fields"),
remark: text("remark"), });
overriddenFields: text("overridden_fields"),
},
(table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)]
);

View file

@ -1,4 +1,4 @@
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js"; import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js"; import { circuitSections } from "./circuit-sections.js";
@ -26,9 +26,6 @@ export const circuits = sqliteTable(
isReserve: integer("is_reserve").notNull().default(0), isReserve: integer("is_reserve").notNull().default(0),
remark: text("remark"), remark: text("remark"),
}, },
(table) => [ (table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)]
unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier),
index("circuits_section_id_idx").on(table.sectionId),
]
); );

View file

@ -144,9 +144,6 @@ function assertCircuitDeviceRowUpdateFieldValue(
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number.`); throw new Error(`${field} must be a non-negative finite number.`);
} }
if (field === "simultaneityFactor" && value > 1) {
throw new Error("simultaneityFactor must not exceed 1.");
}
return; return;
} }
if (field === "cosPhi") { if (field === "cosPhi") {

View file

@ -134,9 +134,6 @@ export function assertCircuitDeviceRowInsertProjectCommand(
row.simultaneityFactor, row.simultaneityFactor,
"row.simultaneityFactor" "row.simultaneityFactor"
); );
if (row.simultaneityFactor > 1) {
throw new Error("row.simultaneityFactor must not exceed 1.");
}
if (row.cosPhi !== null) { if (row.cosPhi !== null) {
assertFiniteNumber(row.cosPhi, "row.cosPhi"); assertFiniteNumber(row.cosPhi, "row.cosPhi");
if (row.cosPhi <= 0) { if (row.cosPhi <= 0) {

View file

@ -0,0 +1,24 @@
export interface CircuitDeviceRow {
id: string;
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
manualQuantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}

View file

@ -71,33 +71,15 @@ export function assertCircuitProtectionUpdateProjectCommand(
if (target !== null) { if (target !== null) {
assertCircuitProtectionSnapshot(target, circuitId); assertCircuitProtectionSnapshot(target, circuitId);
} }
if (circuitProtectionSnapshotsEqual(expected, target)) { if (JSON.stringify(expected) === JSON.stringify(target)) {
throw new Error("Circuit protection update must change state."); throw new Error("Circuit protection update must change state.");
} }
} }
function circuitProtectionSnapshotsEqual(
left: CircuitProtectionSnapshot | null,
right: CircuitProtectionSnapshot | null
): boolean {
if (left === null || right === null) {
return left === right;
}
return (
left.circuitId === right.circuitId &&
left.type === right.type &&
left.ratedCurrentA === right.ratedCurrentA &&
left.fuseUtilizationCategory === right.fuseUtilizationCategory &&
left.tripCharacteristic === right.tripCharacteristic &&
left.rcdType === right.rcdType &&
left.ratedResidualCurrentMa === right.ratedResidualCurrentMa
);
}
export function assertCircuitProtectionSnapshot( export function assertCircuitProtectionSnapshot(
value: unknown, value: unknown,
circuitId: string circuitId: string
): asserts value is CircuitProtectionSnapshot { ) {
if ( if (
!isPlainObject(value) || !isPlainObject(value) ||
Object.keys(value).length !== 7 || Object.keys(value).length !== 7 ||

View file

@ -0,0 +1,9 @@
export interface CircuitSection {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
}

View file

@ -0,0 +1,19 @@
export interface Circuit {
id: string;
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
}

View file

@ -130,7 +130,7 @@ const circuitDeviceRowSchema = z.preprocess(
quantity: finiteNumberSchema.nonnegative(), quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(), manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1), simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(), cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema, remark: nullableStringSchema,
overriddenFields: nullableStringSchema, overriddenFields: nullableStringSchema,

View file

@ -268,10 +268,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [editingCell, setEditingCell] = useState<EditingCell | null>(null); const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null); const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// Synchronous re-entry guard: isSaving is React state and only reflects
// reality after the next render, so a second click/drop fired within the
// same tick could otherwise race past it and double-submit a command.
const commandInFlightRef = useRef(false);
const [componentEditorIntent, setComponentEditorIntent] = const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null); useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
@ -727,27 +723,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
); );
}, [data]); }, [data]);
// Clears the sidebar's target selection once it no longer resolves to a
// real section/circuit (e.g. deleted, moved or renumbered elsewhere)
// instead of silently holding a stale id after a tree reload.
useEffect(() => {
if (!data) {
return;
}
if (
targetSectionId &&
!data.sections.some((section) => section.id === targetSectionId)
) {
setTargetSectionId(null);
}
if (
targetCircuitId &&
!circuitOptions.some((option) => option.id === targetCircuitId)
) {
setTargetCircuitId(null);
}
}, [data, circuitOptions, targetSectionId, targetCircuitId]);
const allCircuits = useMemo( const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [], () => data?.sections.flatMap((section) => section.circuits) ?? [],
[data] [data]
@ -1071,10 +1046,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Runs a normal command. The server records it in project-wide history. // Runs a normal command. The server records it in project-wide history.
async function runCommand(command: HistoryCommand) { async function runCommand(command: HistoryCommand) {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try { try {
setError(null); setError(null);
setIsSaving(true); setIsSaving(true);
@ -1085,7 +1056,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false }); await loadTree({ showLoading: false });
setError(message); setError(message);
} finally { } finally {
commandInFlightRef.current = false;
setIsSaving(false); setIsSaving(false);
} }
} }
@ -1093,10 +1063,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Applies the next eligible project-wide history operation. Selection is only // Applies the next eligible project-wide history operation. Selection is only
// a best-effort local hint; command eligibility and data changes stay server-owned. // a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") { async function applyHistory(mode: "undo" | "redo") {
if (commandInFlightRef.current) {
return;
}
commandInFlightRef.current = true;
try { try {
setError(null); setError(null);
setHistoryBusy(true); setHistoryBusy(true);
@ -1122,7 +1088,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await loadTree({ showLoading: false }); await loadTree({ showLoading: false });
setError(message); setError(message);
} finally { } finally {
commandInFlightRef.current = false;
setIsSaving(false); setIsSaving(false);
setHistoryBusy(false); setHistoryBusy(false);
} }
@ -1779,7 +1744,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) { if (!section) {
throw new Error("Bereich wurde nicht gefunden."); throw new Error("Bereich wurde nicht gefunden.");
} }
const next = await getNextCircuitIdentifier(projectId, sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const isDeviceField = deviceFieldKeys.has(key); const isDeviceField = deviceFieldKeys.has(key);
@ -1968,7 +1933,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({ await runCommand({
label: "Stromkreis hinzufügen", label: "Stromkreis hinzufügen",
redo: async () => { redo: async () => {
const next = await getNextCircuitIdentifier(projectId, sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
const circuit = createCircuitSnapshot({ const circuit = createCircuitSnapshot({
sectionId, sectionId,
@ -2162,7 +2127,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) { if (!section) {
throw new Error("Der Zielbereich ist ungültig."); throw new Error("Der Zielbereich ist ungültig.");
} }
const next = await getNextCircuitIdentifier(projectId, sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const circuit = createCircuitSnapshot( const circuit = createCircuitSnapshot(
@ -2573,7 +2538,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await runCommand({ await runCommand({
label: newCircuitLabel, label: newCircuitLabel,
redo: async () => { redo: async () => {
const next = await getNextCircuitIdentifier(projectId, intent.sectionId); const next = await getNextCircuitIdentifier(intent.sectionId);
const sortOrder = const sortOrder =
intent.targetCircuitId && intent.placement intent.targetCircuitId && intent.placement
? getAdjacentInsertionSortOrder( ? getAdjacentInsertionSortOrder(
@ -3734,7 +3699,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={ disabled={
isSaving ||
!buildCircuitGroupReorderAssignments( !buildCircuitGroupReorderAssignments(
data.sections, data.sections,
section.id, section.id,
@ -3752,7 +3716,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={ disabled={
isSaving ||
!buildCircuitGroupReorderAssignments( !buildCircuitGroupReorderAssignments(
data.sections, data.sections,
section.id, section.id,
@ -3770,7 +3733,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={ disabled={
isSaving ||
hasActiveSortOrFilter || hasActiveSortOrFilter ||
!section.category || !section.category ||
!canRenumberCircuitGroups( !canRenumberCircuitGroups(
@ -3796,7 +3758,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
title={ title={
canDeleteCircuitGroup(section) canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen" ? "Leere Stromkreisgruppe entfernen"
@ -3854,18 +3815,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
Gruppen-FI hinzufügen Gruppen-FI hinzufügen
</button> </button>
<button <button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen Stromkreis hinzufügen
</button> </button>
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={hasActiveSortOrFilter || isSaving} disabled={hasActiveSortOrFilter}
onClick={() => void handleRenumberSection(section.id)} onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined} title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
> >
@ -4470,7 +4426,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)} onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
> >
Manuelles Gerät hinzufügen Manuelles Gerät hinzufügen
@ -4478,7 +4433,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
onClick={() => void handleDeleteCircuit(row.circuit!.id)} onClick={() => void handleDeleteCircuit(row.circuit!.id)}
> >
Stromkreis löschen Stromkreis löschen
@ -4486,7 +4440,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</> </>
) : null} ) : null}
{row.device ? ( {row.device ? (
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}> <button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen Gerät löschen
</button> </button>
) : null} ) : null}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react"; import React, { type FormEvent, type ReactNode } from "react";
interface FormModalProps { interface FormModalProps {
children: ReactNode; children: ReactNode;
@ -14,9 +14,6 @@ interface FormModalProps {
title: string; title: string;
} }
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function FormModal({ export function FormModal({
children, children,
description, description,
@ -28,56 +25,11 @@ export function FormModal({
submitLabel, submitLabel,
title, title,
}: FormModalProps) { }: FormModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Focuses the dialog on open and returns focus to the element that
// triggered it on close, so keyboard users never lose their place in the
// grid behind the backdrop.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const firstFocusable =
dialogRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
firstFocusable?.focus();
return () => {
previouslyFocused?.focus();
};
}, []);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
if (!isSaving) {
event.stopPropagation();
onClose();
}
return;
}
if (event.key !== "Tab" || !dialogRef.current) {
return;
}
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
);
if (focusable.length === 0) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
return ( return (
<> <>
<div <div
aria-modal="true" aria-modal="true"
className="modal fade show d-block" className="modal fade show d-block"
onKeyDown={handleKeyDown}
ref={dialogRef}
role="dialog" role="dialog"
tabIndex={-1} tabIndex={-1}
> >

View file

@ -7,7 +7,6 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board"; } from "../../shared/constants/distribution-board";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput { export interface ProjectSettingsInput {
name: string; name: string;
@ -132,278 +131,319 @@ export function ProjectSettingsModal({
} }
return ( return (
<FormModal <>
description="Stammdaten und elektrische Standardwerte des Projekts" <div
isSaving={isSaving} aria-labelledby="project-settings-title"
onClose={onClose} aria-modal="true"
onSubmit={handleSubmit} className="modal fade show d-block"
submitDisabled={!isValid} role="dialog"
submitLabel="Einstellungen speichern" tabIndex={-1}
title="Projekteinstellungen" >
> <div className="modal-dialog modal-lg modal-dialog-centered">
<div className="row g-3"> <form className="modal-content" onSubmit={handleSubmit}>
<div className="col-12"> <div className="modal-header">
<label className="form-label" htmlFor="project-name"> <div>
Projektname <h2 className="modal-title fs-5" id="project-settings-title">
</label> Projekteinstellungen
<input </h2>
autoFocus <p className="text-secondary small mb-0">
className="form-control" Stammdaten und elektrische Standardwerte des Projekts
id="project-name" </p>
maxLength={200} </div>
onChange={(event) => setName(event.target.value)} <button
required aria-label="Schließen"
value={name} className="btn-close"
/> disabled={isSaving}
</div> onClick={onClose}
<div className="col-12 col-md-6"> type="button"
<label className="form-label" htmlFor="internal-project-number"> />
Projektnummer intern </div>
</label> <div className="modal-body">
<input <div className="row g-3">
className="form-control" <div className="col-12">
id="internal-project-number" <label className="form-label" htmlFor="project-name">
maxLength={100} Projektname
onChange={(event) => </label>
setInternalProjectNumber(event.target.value) <input
} autoFocus
value={internalProjectNumber} className="form-control"
/> id="project-name"
</div> maxLength={200}
<div className="col-12 col-md-6"> onChange={(event) => setName(event.target.value)}
<label className="form-label" htmlFor="external-project-number"> required
Projektnummer extern value={name}
</label> />
<input </div>
className="form-control" <div className="col-12 col-md-6">
id="external-project-number" <label className="form-label" htmlFor="internal-project-number">
maxLength={100} Projektnummer intern
onChange={(event) => </label>
setExternalProjectNumber(event.target.value) <input
} className="form-control"
value={externalProjectNumber} id="internal-project-number"
/> maxLength={100}
</div> onChange={(event) =>
<div className="col-12"> setInternalProjectNumber(event.target.value)
<fieldset> }
<legend className="form-label mb-1"> value={internalProjectNumber}
Verwendete Netzarten />
</legend> </div>
<p className="form-text mt-0"> <div className="col-12 col-md-6">
Nur ausgewählte Netzarten stehen bei Verteilungen zur <label className="form-label" htmlFor="external-project-number">
Auswahl. Bereits verwendete Netzarten können nicht Projektnummer extern
deaktiviert werden. </label>
</p> <input
<div className="row g-2"> className="form-control"
{distributionBoardSupplyTypes.map((supplyType) => ( id="external-project-number"
<div maxLength={100}
className="col-12 col-md-6" onChange={(event) =>
key={supplyType} setExternalProjectNumber(event.target.value)
> }
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<fieldset>
<legend className="form-label mb-1">
Verwendete Netzarten
</legend>
<p className="form-text mt-0">
Nur ausgewählte Netzarten stehen bei Verteilungen zur
Auswahl. Bereits verwendete Netzarten können nicht
deaktiviert werden.
</p>
<div className="row g-2">
{distributionBoardSupplyTypes.map((supplyType) => (
<div
className="col-12 col-md-6"
key={supplyType}
>
<label className="form-check">
<input
checked={enabledDistributionBoardSupplyTypes.includes(
supplyType
)}
className="form-check-input"
disabled={usedDistributionBoardSupplyTypes.includes(
supplyType
)}
onChange={() => toggleSupplyType(supplyType)}
type="checkbox"
/>
<span className="form-check-label">
{distributionBoardSupplyTypeLabels[supplyType]}
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span>
</label>
</div>
))}
</div>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div>
) : null}
</fieldset>
</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">
<label className="form-check"> <label className="form-check">
<input <input
checked={enabledDistributionBoardSupplyTypes.includes( checked={isPublicBuilding}
supplyType
)}
className="form-check-input" className="form-check-input"
disabled={usedDistributionBoardSupplyTypes.includes( id="public-building"
supplyType onChange={(event) =>
)} setIsPublicBuilding(event.target.checked)
onChange={() => toggleSupplyType(supplyType)} }
type="checkbox" type="checkbox"
/> />
<span className="form-check-label"> <span className="form-check-label">
{distributionBoardSupplyTypeLabels[supplyType]} Öffentliches Gebäude
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span> </span>
</label> </label>
<div className="form-text ms-4">
Wird bei der späteren Leitungsauslegung berücksichtigt,
insbesondere bei der Auswahl halogenfreier Kabel und
Leitungen.
</div>
</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 className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>
<p className="text-secondary small">
Der Export enthält den vollständigen unterstützten
Projektzustand. Beim Duplizieren bleibt dieses Projekt
unverändert.
</p>
<button
className="btn btn-outline-primary mb-3"
disabled={isSaving}
onClick={() => void onExport()}
type="button"
>
Projektdatei herunterladen
</button>
<label className="form-label d-block" htmlFor="project-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
id="project-import"
onChange={(event) =>
void handleTransferFile(event.target.files?.[0])
}
type="file"
/>
{transferFilename ? (
<div className="form-text">{transferFilename} ist bereit.</div>
) : null}
{fileError ? (
<div className="text-danger small mt-1">{fileError}</div>
) : null}
<div className="d-flex flex-wrap gap-3 mt-3">
<label className="form-check">
<input
checked={importMode === "duplicate"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("duplicate")}
type="radio"
/>
<span className="form-check-label">
Als neues Projekt duplizieren
</span>
</label>
<label className="form-check">
<input
checked={importMode === "replace"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("replace")}
type="radio"
/>
<span className="form-check-label">
Dieses Projekt ersetzen
</span>
</label>
</div>
{importMode === "replace" ? (
<label className="form-check mt-2">
<input
checked={replaceConfirmed}
className="form-check-input"
onChange={(event) =>
setReplaceConfirmed(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label text-danger">
Ich bestätige, dass der aktuelle Projektstand ersetzt
wird.
</span>
</label>
) : null}
<button
className="btn btn-outline-danger mt-3"
disabled={
isSaving ||
transfer === null ||
(importMode === "replace" && !replaceConfirmed)
}
onClick={() =>
transfer === null
? undefined
: void onImport(transfer, importMode)
}
type="button"
>
Projektdatei importieren
</button>
</div> </div>
))}
</div>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div> </div>
) : null} </div>
</fieldset> <div className="modal-footer">
</div> <button
<div className="col-12"> className="btn btn-outline-secondary"
<label className="form-label" htmlFor="building-owner"> disabled={isSaving}
Bauherr onClick={onClose}
</label> type="button"
<input >
className="form-control" Abbrechen
id="building-owner" </button>
maxLength={200} <button
onChange={(event) => setBuildingOwner(event.target.value)} className="btn btn-primary"
value={buildingOwner} disabled={isSaving || !isValid}
/> type="submit"
</div> >
<div className="col-12"> {isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
<label className="form-label" htmlFor="project-description"> </button>
Beschreibung </div>
</label> </form>
<textarea
className="form-control"
id="project-description"
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
rows={4}
value={description}
/>
</div>
<div className="col-12">
<label className="form-check">
<input
checked={isPublicBuilding}
className="form-check-input"
id="public-building"
onChange={(event) =>
setIsPublicBuilding(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label">
Öffentliches Gebäude
</span>
</label>
<div className="form-text ms-4">
Wird bei der späteren Leitungsauslegung berücksichtigt,
insbesondere bei der Auswahl halogenfreier Kabel und
Leitungen.
</div>
</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 className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>
<p className="text-secondary small">
Der Export enthält den vollständigen unterstützten
Projektzustand. Beim Duplizieren bleibt dieses Projekt
unverändert.
</p>
<button
className="btn btn-outline-primary mb-3"
disabled={isSaving}
onClick={() => void onExport()}
type="button"
>
Projektdatei herunterladen
</button>
<label className="form-label d-block" htmlFor="project-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
id="project-import"
onChange={(event) =>
void handleTransferFile(event.target.files?.[0])
}
type="file"
/>
{transferFilename ? (
<div className="form-text">{transferFilename} ist bereit.</div>
) : null}
{fileError ? (
<div className="text-danger small mt-1">{fileError}</div>
) : null}
<div className="d-flex flex-wrap gap-3 mt-3">
<label className="form-check">
<input
checked={importMode === "duplicate"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("duplicate")}
type="radio"
/>
<span className="form-check-label">
Als neues Projekt duplizieren
</span>
</label>
<label className="form-check">
<input
checked={importMode === "replace"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("replace")}
type="radio"
/>
<span className="form-check-label">
Dieses Projekt ersetzen
</span>
</label>
</div>
{importMode === "replace" ? (
<label className="form-check mt-2">
<input
checked={replaceConfirmed}
className="form-check-input"
onChange={(event) =>
setReplaceConfirmed(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label text-danger">
Ich bestätige, dass der aktuelle Projektstand ersetzt
wird.
</span>
</label>
) : null}
<button
className="btn btn-outline-danger mt-3"
disabled={
isSaving ||
transfer === null ||
(importMode === "replace" && !replaceConfirmed)
}
onClick={() =>
transfer === null
? undefined
: void onImport(transfer, importMode)
}
type="button"
>
Projektdatei importieren
</button>
</div> </div>
</div> </div>
</FormModal> <div className="modal-backdrop fade show" />
</>
); );
} }

View file

@ -688,9 +688,9 @@ export function deleteCircuitCommand(
); );
} }
export function getNextCircuitIdentifier(projectId: string, sectionId: string) { export function getNextCircuitIdentifier(sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>( return request<{ sectionId: string; nextIdentifier: string }>(
`/api/projects/${projectId}/circuit-sections/${sectionId}/next-identifier` `/api/circuit-sections/${sectionId}/next-identifier`
); );
} }

View file

@ -396,16 +396,6 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
if (trimmed === "") { if (trimmed === "") {
return undefined; return undefined;
} }
// A "." is ambiguous in German number entry: it could be a decimal point
// (English convention) or a thousands separator (e.g. "1.500" meaning
// one thousand five hundred). Silently guessing either way risks a wrong
// value entering the power balance without any visible error, so "."
// is rejected outright and "," is the only accepted decimal separator.
if (trimmed.includes(".")) {
throw new Error(
`Ungültiger Zahlenwert in ${cellKey}: Dezimalstellen mit Komma eingeben.`
);
}
const parsed = Number(trimmed.replace(",", ".")); const parsed = Number(trimmed.replace(",", "."));
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`); throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);

View file

@ -169,6 +169,13 @@ function makeVisibleGridRow(
return { rowKey, rowType, sectionId, circuit, device, cells }; return { rowKey, rowType, sectionId, circuit, device, cells };
} }
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
return buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
}
export function buildVisibleGridRowsWithStructure( export function buildVisibleGridRowsWithStructure(
sections: readonly CircuitTreeSectionDto[], sections: readonly CircuitTreeSectionDto[],
structure: { structure: {

View file

@ -259,28 +259,22 @@ export function buildCircuitGroupRenumberPlan(
targetGroupNumber, targetGroupNumber,
expectedPrefix, expectedPrefix,
targetPrefix: formatGroupPrefix(category, targetGroupNumber), targetPrefix: formatGroupPrefix(category, targetGroupNumber),
circuits: [...group.circuits] circuits: group.circuits.map((circuit) => {
.sort( const circuitNumber = parseCircuitNumber(
(left, right) => circuit.equipmentIdentifier,
left.sortOrder - right.sortOrder || category,
left.id.localeCompare(right.id) group.groupNumber
) );
.map((circuit) => { return {
const circuitNumber = parseCircuitNumber( circuitId: circuit.id,
circuit.equipmentIdentifier, expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
category, category,
group.groupNumber targetGroupNumber,
); circuitNumber
return { ),
circuitId: circuit.id, };
expectedEquipmentIdentifier: circuit.equipmentIdentifier, }),
targetEquipmentIdentifier: formatCircuitIdentifier(
category,
targetGroupNumber,
circuitNumber
),
};
}),
components: group.components.map((component) => ({ components: group.components.map((component) => ({
componentId: component.id, componentId: component.id,
expectedEquipmentIdentifier: component.equipmentIdentifier, expectedEquipmentIdentifier: component.equipmentIdentifier,

View file

@ -23,13 +23,9 @@ export function buildCircuitSectionRenumberAssignments(
) )
) )
); );
const orderedCircuits = [...targetSection.circuits].sort(
(left, right) =>
left.sortOrder - right.sortOrder || left.id.localeCompare(right.id)
);
const assignments: CircuitSectionRenumberAssignment[] = []; const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1; let suffix = 1;
for (const circuit of orderedCircuits) { for (const circuit of targetSection.circuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`; let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while ( while (
identifiersOutsideSection.has(targetEquipmentIdentifier) identifiersOutsideSection.has(targetEquipmentIdentifier)

View file

@ -64,7 +64,7 @@ export function buildCircuitStructureProjection(
component, component,
}); });
} }
for (const circuit of ordered(section.circuits)) { for (const circuit of section.circuits) {
rows.push({ rows.push({
rowKey: `circuit-block:${circuit.id}`, rowKey: `circuit-block:${circuit.id}`,
rowType: "circuitBlock", rowType: "circuitBlock",

View file

@ -47,24 +47,10 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt", "circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt", "circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-floor.insert": "Geschoss angelegt", "project-floor.insert": "Geschoss angelegt",
"project-floor.update": "Geschoss bearbeitet",
"project-floor.delete": "Geschoss entfernt", "project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt", "project-room.insert": "Raum angelegt",
"project-room.update": "Raum bearbeitet",
"project-room.delete": "Raum entfernt", "project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt", "project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen",
"external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit":
"Externes Objekt aus erzeugtem Stromkreis gelöst",
"external-object.assign-to-new-row":
"Externes Objekt in neue Gerätezeile übernommen",
"external-object.unassign-and-delete-created-row":
"Externes Objekt aus erzeugter Gerätezeile gelöst",
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
}; };
export function getProjectRevisionSourceLabel( export function getProjectRevisionSourceLabel(

View file

@ -7,13 +7,11 @@ export function registerNodeInstrumentation() {
logger.info("web server starting", { pid: process.pid }); logger.info("web server starting", { pid: process.pid });
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error("uncaught exception, exiting", toErrorMeta(error)); logger.error("uncaught exception", toErrorMeta(error));
process.exit(1);
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
logger.error("unhandled rejection, exiting", toErrorMeta(reason)); logger.error("unhandled rejection", toErrorMeta(reason));
process.exit(1);
}); });
setInterval(() => { setInterval(() => {

View file

@ -5,14 +5,10 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation"); const logger = createLogger("web:navigation");
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
// The Docker healthcheck hits "/" every few seconds with no User-Agent logger.info("page request", {
// header; skip it so real navigation isn't drowned out in the logs. method: request.method,
if (request.headers.get("user-agent")) { path: request.nextUrl.pathname,
logger.info("page request", { });
method: request.method,
path: request.nextUrl.pathname,
});
}
return NextResponse.next(); return NextResponse.next();
} }

View file

@ -1,21 +1,10 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js"; import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../composition/application-repositories.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) { export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { projectId, sectionId } = req.params; const { sectionId } = req.params;
if (typeof projectId !== "string" || typeof sectionId !== "string") { if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid parameters" }); return res.status(400).json({ error: "Invalid sectionId" });
}
const section = await circuitSectionRepository.findById(sectionId);
const list = section
? await circuitListRepository.findById(projectId, section.circuitListId)
: null;
if (!section || !list) {
return res.status(404).json({ error: "Section not found" });
} }
try { try {
const nextIdentifier = const nextIdentifier =

View file

@ -34,12 +34,11 @@ export async function updateGlobalDevice(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const existing = await globalDeviceRepository.findById(globalDeviceId);
if (!existing) {
return res.status(404).json({ error: "Global device not found" });
}
await globalDeviceRepository.update(globalDeviceId, parsed.data); await globalDeviceRepository.update(globalDeviceId, parsed.data);
const row = await globalDeviceRepository.findById(globalDeviceId); const row = await globalDeviceRepository.findById(globalDeviceId);
if (!row) {
return res.status(404).json({ error: "Global device not found" });
}
return res.json(row); return res.json(row);
} }

View file

@ -1,4 +1,5 @@
import express from "express"; import express from "express";
import { circuitRouter } from "./routes/circuit.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js"; import { projectRouter } from "./routes/project.routes.js";
@ -30,15 +31,6 @@ app.use((req, res, next) => {
else if (res.statusCode >= 400) logger.warn("request completed", meta); else if (res.statusCode >= 400) logger.warn("request completed", meta);
else logger.info("request completed", meta); else logger.info("request completed", meta);
}); });
res.on("close", () => {
if (!res.writableEnded) {
logger.warn("request aborted before response finished", {
method: req.method,
path: req.originalUrl,
durationMs: Date.now() - startedAt,
});
}
});
next(); next();
}); });
@ -47,19 +39,18 @@ app.get("/health", (_req, res) => {
}); });
app.use("/api/projects", projectRouter); app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api/global-devices", globalDeviceRouter); app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter); app.use("/api/project-devices", projectDeviceRouter);
app.use(errorMiddleware); app.use(errorMiddleware);
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error("uncaught exception, exiting", toErrorMeta(error)); logger.error("uncaught exception", toErrorMeta(error));
process.exit(1);
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
logger.error("unhandled rejection, exiting", toErrorMeta(reason)); logger.error("unhandled rejection", toErrorMeta(reason));
process.exit(1);
}); });
process.on("SIGTERM", () => logger.info("received SIGTERM")); process.on("SIGTERM", () => logger.info("received SIGTERM"));

View file

@ -0,0 +1,9 @@
import { Router } from "express";
import {
getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js";
export const circuitRouter = Router();
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);

View file

@ -16,7 +16,6 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js"; import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js"; import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import { import {
getProjectHistory, getProjectHistory,
listProjectRevisions, listProjectRevisions,
@ -96,10 +95,6 @@ projectRouter.delete(
); );
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier",
getNextCircuitIdentifier
);
projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor); projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor); projectRouter.put("/:projectId/floors/:floorId", updateFloor);

View file

@ -47,19 +47,13 @@ export function createLogger(
if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[threshold]) { if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[threshold]) {
return; return;
} }
const timestamp = new Date().toISOString(); const line = JSON.stringify({
let line: string; timestamp: new Date().toISOString(),
try { level,
line = JSON.stringify({ timestamp, level, scope, message, ...meta }); scope,
} catch { message,
line = JSON.stringify({ ...meta,
timestamp, });
level,
scope,
message,
logError: "failed to serialize log metadata",
});
}
if (level === "error" || level === "warn") { if (level === "error" || level === "warn") {
console.error(line); console.error(line);
} else { } else {

View file

@ -1,10 +1,5 @@
import { z } from "zod"; import { z } from "zod";
// Base64 length of the 18 MiB transport limit documented for CSV uploads
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
// padding). Keep in sync with the controller's raw byte-length check.
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
export const updateExternalCsvConfigurationSchema = z export const updateExternalCsvConfigurationSchema = z
.object({ .object({
expectedRevision: z.number().int().nonnegative(), expectedRevision: z.number().int().nonnegative(),
@ -15,7 +10,7 @@ export const updateExternalCsvConfigurationSchema = z
export const previewExternalCsvSchema = z export const previewExternalCsvSchema = z
.object({ .object({
fileName: z.string().trim().min(1).max(255), fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH), contentBase64: z.string().min(1).max(24_000_000),
}) })
.strict(); .strict();
@ -26,7 +21,7 @@ export const applyExternalInitialImportSchema = z.object({
expectedConfigurationVersion: z.number().int().positive(), expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255), fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH), contentBase64: z.string().min(1).max(24_000_000),
sourceName: z.string().trim().min(1).max(200), sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({ roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1), sourceRoomKey: z.string().trim().min(1),

View file

@ -1,15 +1,15 @@
import { z } from "zod"; import { z } from "zod";
export const createGlobalDeviceSchema = z.object({ export const createGlobalDeviceSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1),
displayName: z.string().min(1).max(200), displayName: z.string().min(1),
category: z.string().max(100).optional(), category: z.string().optional(),
quantity: z.number().min(0), quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0), installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1), demandFactor: z.number().min(0).max(1),
phaseCount: z.union([z.literal(1), z.literal(3)]), phaseCount: z.union([z.literal(1), z.literal(3)]),
powerFactor: z.number().min(0).max(1).optional(), powerFactor: z.number().min(0).max(1).optional(),
note: z.string().max(2000).optional(), note: z.string().optional(),
}).strict(); }).strict();
export const updateGlobalDeviceSchema = createGlobalDeviceSchema; export const updateGlobalDeviceSchema = createGlobalDeviceSchema;

View file

@ -4,16 +4,16 @@ import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
import { circuitGroupCategories } from "../constants/circuit-group.js"; import { circuitGroupCategories } from "../constants/circuit-group.js";
export const createProjectDeviceSchema = z.object({ export const createProjectDeviceSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1),
displayName: z.string().min(1).max(200), displayName: z.string().min(1),
connectionKind: z.string().max(100).optional(), connectionKind: z.string().optional(),
costGroup: z.string().max(100).optional(), costGroup: z.string().optional(),
category: z.enum(circuitGroupCategories), category: z.enum(circuitGroupCategories),
quantity: z.number().min(0), quantity: z.number().min(0),
powerPerUnit: z.number().min(0), powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1), simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(), cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().max(2000).optional(), remark: z.string().optional(),
}).strict(); }).strict();
export const updateProjectDeviceSchema = createProjectDeviceSchema; export const updateProjectDeviceSchema = createProjectDeviceSchema;

View file

@ -36,7 +36,7 @@ export const updateProjectSettingsSchema = z
export const createDistributionBoardSchema = z export const createDistributionBoardSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200), name: z.string().trim().min(1),
floorId: z.string().trim().min(1).nullable(), floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes), supplyType: z.enum(distributionBoardSupplyTypes),
}) })
@ -67,7 +67,7 @@ export const deleteDistributionBoardSchema = z
export const createFloorSchema = z export const createFloorSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200), name: z.string().trim().min(1),
}) })
.strict(); .strict();
@ -83,8 +83,8 @@ export const createRoomSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).optional(), floorId: z.string().trim().min(1).optional(),
roomNumber: z.string().trim().min(1).max(50), roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1).max(200), roomName: z.string().trim().min(1),
}) })
.strict(); .strict();
@ -92,8 +92,8 @@ export const updateRoomSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(), floorId: z.string().trim().min(1).nullable(),
roomNumber: z.string().trim().min(1).max(50), roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1).max(200), roomName: z.string().trim().min(1),
}) })
.strict(); .strict();

View file

@ -187,25 +187,12 @@ describe("circuit grid model", () => {
}); });
it("parses numeric drafts and rejects invalid values", () => { it("parses numeric drafts and rejects invalid values", () => {
assert.equal(parseNumeric("quantity", " 1500 "), 1500); assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25); assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25);
assert.equal(parseNumeric("quantity", ""), undefined); assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/); assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
}); });
it("rejects a dot instead of silently misreading it as a thousands separator", () => {
// "1.500" typed with German thousands-separator intent (meaning 1500)
// must never silently become 1.5 — it must fail loudly instead.
assert.throws(
() => parseNumeric("cableLength", "1.500"),
/Dezimalstellen mit Komma eingeben/
);
assert.throws(
() => parseNumeric("powerPerUnit", "2.5"),
/Dezimalstellen mit Komma eingeben/
);
});
it("builds nullable circuit command patches from grid drafts", () => { it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), { assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null, voltage: null,

View file

@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
buildVisibleGridRows,
buildVisibleGridRowsWithStructure, buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections, filterAndSortCircuitSections,
getDistinctFilterValues, getDistinctFilterValues,
@ -100,7 +101,7 @@ describe("circuit grid projection", () => {
]; ];
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null); const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
const rows = buildVisibleGridRowsWithStructure(projectedSections, { headerComponents: [], footerComponents: [] }); const rows = buildVisibleGridRows(projectedSections);
assert.equal(projectedSections.length, 2); assert.equal(projectedSections.length, 2);
assert.deepEqual( assert.deepEqual(
@ -149,7 +150,7 @@ describe("circuit grid projection", () => {
}); });
it("builds the compact, grouped, reserve and placeholder row shapes", () => { it("builds the compact, grouped, reserve and placeholder row shapes", () => {
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] }); const rows = buildVisibleGridRows(sections);
assert.deepEqual(rows.map((row) => row.rowType), [ assert.deepEqual(rows.map((row) => row.rowType), [
"section", "section",
@ -190,7 +191,7 @@ describe("circuit grid projection", () => {
}, },
})), })),
})); }));
const rows = buildVisibleGridRowsWithStructure(protectedSections, { headerComponents: [], footerComponents: [] }); const rows = buildVisibleGridRows(protectedSections);
const protectionValue = (rowType: string) => const protectionValue = (rowType: string) =>
rows rows
.find((row) => row.rowType === rowType) .find((row) => row.rowType === rowType)

View file

@ -580,43 +580,4 @@ describe("circuit structure project-command repository", () => {
fixture.context.close(); fixture.context.close();
} }
}); });
it("rejects a BMK that differs from an existing one only by German umlaut casing", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-original",
equipmentIdentifier: "-1F9Ä",
deviceRows: [],
})
),
});
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-duplicate",
equipmentIdentifier: "-1f9ä",
deviceRows: [],
})
),
}),
/Duplicate equipmentIdentifier in circuit list\./
);
} finally {
fixture.context.close();
}
});
}); });

View file

@ -1,100 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getNextCircuitIdentifier } from "../src/server/controllers/circuit.controller.js";
import {
circuitListRepository,
circuitSectionRepository,
} from "../src/server/composition/application-repositories.js";
import { circuitNumberingService } from "../src/server/composition/circuit-numbering-service.js";
function createMockResponse() {
let statusCode = 200;
let body: unknown;
return {
response: {
status(code: number) {
statusCode = code;
return this;
},
json(value: unknown) {
body = value;
return this;
},
},
getStatusCode: () => statusCode,
getBody: () => body,
};
}
describe("circuit controller", () => {
it("returns the next identifier when the section belongs to the project", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
findList: circuitListRepository.findById,
getNextIdentifier: circuitNumberingService.getNextIdentifier,
};
circuitSectionRepository.findById = async () =>
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
circuitListRepository.findById = async (projectId: string, circuitListId: string) =>
projectId === "project-1" && circuitListId === "list-1"
? ({ id: "list-1", projectId: "project-1" } as never)
: null;
circuitNumberingService.getNextIdentifier = async () => "-1F3";
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "project-1", sectionId: "section-1" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
circuitListRepository.findById = originals.findList;
circuitNumberingService.getNextIdentifier = originals.getNextIdentifier;
}
assert.deepEqual(mock.getBody(), {
sectionId: "section-1",
nextIdentifier: "-1F3",
});
});
it("returns 404 instead of leaking numbering state for a section from another project", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
findList: circuitListRepository.findById,
};
circuitSectionRepository.findById = async () =>
({ id: "section-1", circuitListId: "list-1", prefix: "-1F" }) as never;
// The section exists, but its circuit list does not belong to the requesting project.
circuitListRepository.findById = async () => null;
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "foreign-project", sectionId: "section-1" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
circuitListRepository.findById = originals.findList;
}
assert.equal(mock.getStatusCode(), 404);
assert.deepEqual(mock.getBody(), { error: "Section not found" });
});
it("returns 404 for a section that does not exist", async () => {
const originals = {
findSection: circuitSectionRepository.findById,
};
circuitSectionRepository.findById = async () => null;
const mock = createMockResponse();
try {
await getNextCircuitIdentifier(
{ params: { projectId: "project-1", sectionId: "missing" } } as never,
mock.response as never
);
} finally {
circuitSectionRepository.findById = originals.findSection;
}
assert.equal(mock.getStatusCode(), 404);
assert.deepEqual(mock.getBody(), { error: "Section not found" });
});
});

View file

@ -580,7 +580,7 @@ describe("distribution-board component structure project command", () => {
snapshot snapshot
), ),
}), }),
/Duplicate equipmentIdentifier in circuit list\./ /UNIQUE constraint failed/
); );
assert.equal( assert.equal(
context.db.select().from(projectRevisions).all().length, context.db.select().from(projectRevisions).all().length,

View file

@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
applyExternalInitialImportSchema, applyExternalInitialImportSchema,
MAX_CSV_CONTENT_BASE64_LENGTH,
planExternalInitialImportSchema, planExternalInitialImportSchema,
previewExternalCsvSchema, previewExternalCsvSchema,
updateExternalCsvConfigurationSchema, updateExternalCsvConfigurationSchema,
@ -75,7 +74,7 @@ describe("external CSV API contracts", () => {
assert.equal( assert.equal(
previewExternalCsvSchema.safeParse({ previewExternalCsvSchema.safeParse({
fileName: "revit.csv", fileName: "revit.csv",
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1), contentBase64: "a".repeat(24_000_001),
}).success, }).success,
false false
); );

View file

@ -195,30 +195,6 @@ describe("external initial import project command", () => {
} }
}); });
it("rejects a target state with zero classified objects before writing anything", () => {
// assertPopulatedInitialState requires at least one object, so a CSV
// that classified none must be rejected at command construction, not
// reach the persistence layer's bulk insert.
const { context, configuration } = createTestContext();
try {
const target: ExternalModelStateSnapshot = {
...initialState(configuration),
roomMappings: [],
objects: [],
};
assert.throws(
() =>
createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
/at least one object/
);
} finally {
context.close();
}
});
it("rejects changed state, checksum drift and row assignment", () => { it("rejects changed state, checksum drift and row assignment", () => {
const { context, configuration } = createTestContext(); const { context, configuration } = createTestContext();
try { try {

View file

@ -209,13 +209,6 @@ describe("circuit device-row update project commands", () => {
}), }),
/non-negative/ /non-negative/
); );
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
simultaneityFactor: 1.5,
}),
/must not exceed 1/
);
}); });
}); });
@ -515,14 +508,6 @@ describe("circuit device-row structure project commands", () => {
}), }),
/must not be negative/ /must not be negative/
); );
assert.throws(
() =>
createCircuitDeviceRowInsertProjectCommand({
...row,
simultaneityFactor: 1.5,
}),
/must not exceed 1/
);
assert.throws( assert.throws(
() => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"), () => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"),
/rowId/ /rowId/

View file

@ -191,32 +191,6 @@ describe("project version history presentation", () => {
), ),
"Stromkreisgruppe vollständig wiederhergestellt" "Stromkreisgruppe vollständig wiederhergestellt"
); );
assert.equal(
getProjectRevisionDescription(
revision(16, { commandType: "circuit-protection.update" })
),
"Stromkreisschutz bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(17, { commandType: "project-floor.update" })
),
"Geschoss bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(18, { commandType: "project-room.update" })
),
"Raum bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(19, {
commandType: "external-object.update-row-assignment",
})
),
"Externe Objektzuordnung geändert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal( assert.equal(
getProjectSnapshotKindLabel("automatic"), getProjectSnapshotKindLabel("automatic"),