Compare commits
No commits in common. "a99980c47b08269e13a798eecf72072d092e6ba8" and "ea3c02cd6bdce7650ff1978e94740044cad72286" have entirely different histories.
a99980c47b
...
ea3c02cd6b
55 changed files with 551 additions and 3335 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
24
src/domain/models/circuit-device-row.model.ts
Normal file
24
src/domain/models/circuit-device-row.model.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
9
src/domain/models/circuit-section.model.ts
Normal file
9
src/domain/models/circuit-section.model.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface CircuitSection {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
prefix: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
19
src/domain/models/circuit.model.ts
Normal file
19
src/domain/models/circuit.model.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
12
src/proxy.ts
12
src/proxy.ts
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
9
src/server/routes/circuit.routes.ts
Normal file
9
src/server/routes/circuit.routes.ts
Normal 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);
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue