forked from jappel/leistungsbilanz-ts
Fix code-review findings across domain, persistence, server and frontend
Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:
- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
"ä1") because the DB's normalized index only folds ASCII case. Added
a shared Unicode-aware pre-check used by every circuit/component
insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
level (command model and snapshot/restore schema), unlike every
sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
("1.500" parsed as 1.5); "." is now rejected outright with a clear
message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
re-entrancy guard, so a double click/drop could fire the same
command twice and race a BMK collision or revision conflict. Added a
synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
in other projects (no ownership check, 400 instead of 404). Moved
under /projects/:projectId and scoped it.
Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa96be2d42
commit
b45dc5002d
48 changed files with 3263 additions and 526 deletions
|
|
@ -17,13 +17,13 @@ import {
|
|||
deleteDistributionBoard,
|
||||
disconnectProjectDeviceRows,
|
||||
exportProjectTransfer,
|
||||
getProject,
|
||||
getProjectDeviceSyncPreview,
|
||||
listCircuitLists,
|
||||
listDistributionBoards,
|
||||
listFloors,
|
||||
listGlobalDevices,
|
||||
listProjectDevices,
|
||||
listProjects,
|
||||
listRooms,
|
||||
importProjectTransfer,
|
||||
synchronizeProjectDeviceRows,
|
||||
|
|
@ -129,7 +129,7 @@ export default function ProjectDetailPage() {
|
|||
return;
|
||||
}
|
||||
Promise.all([
|
||||
listProjects(),
|
||||
getProject(projectId),
|
||||
listDistributionBoards(projectId),
|
||||
listCircuitLists(projectId),
|
||||
listFloors(projectId),
|
||||
|
|
@ -138,7 +138,7 @@ export default function ProjectDetailPage() {
|
|||
listGlobalDevices(),
|
||||
])
|
||||
.then(([
|
||||
projects,
|
||||
currentProject,
|
||||
distributionBoards,
|
||||
loadedCircuitLists,
|
||||
loadedFloors,
|
||||
|
|
@ -146,7 +146,6 @@ export default function ProjectDetailPage() {
|
|||
loadedProjectDevices,
|
||||
loadedGlobalDevices,
|
||||
]) => {
|
||||
const currentProject = projects.find((item) => item.id === projectId) ?? null;
|
||||
setProject(currentProject);
|
||||
setBoards(distributionBoards);
|
||||
setCircuitLists(loadedCircuitLists);
|
||||
|
|
|
|||
2
src/db/migrations/0006_damp_skrulls.sql
Normal file
2
src/db/migrations/0006_damp_skrulls.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
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`);
|
||||
2419
src/db/migrations/meta/0006_snapshot.json
Normal file
2419
src/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
|||
"when": 1785687503453,
|
||||
"tag": "0005_stale_gorilla_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1786043080323,
|
||||
"tag": "0006_damp_skrulls",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -43,12 +43,6 @@ export interface CircuitDeviceRowPatchInput {
|
|||
overriddenFields?: string | null;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||
circuitId: string;
|
||||
linkedProjectDeviceId?: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
|
||||
return {
|
||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
||||
|
|
@ -105,15 +99,3 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
|
|||
|
||||
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, ne } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitUpdateProjectCommand,
|
||||
createCircuitUpdateProjectCommand,
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||
|
||||
type CircuitRow = typeof circuits.$inferSelect;
|
||||
|
||||
|
|
@ -166,20 +167,12 @@ export class CircuitProjectCommandRepository
|
|||
) {
|
||||
return;
|
||||
}
|
||||
const duplicate = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(
|
||||
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.");
|
||||
}
|
||||
assertEquipmentIdentifierAvailable(
|
||||
database,
|
||||
circuit.circuitListId,
|
||||
equipmentIdentifier,
|
||||
circuit.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||
|
||||
export class CircuitStructureProjectCommandRepository
|
||||
implements CircuitStructureProjectCommandStore
|
||||
|
|
@ -103,24 +104,11 @@ export class CircuitStructureProjectCommandRepository
|
|||
if (existingCircuit) {
|
||||
throw new Error("Circuit id already exists.");
|
||||
}
|
||||
const duplicateIdentifier = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.circuitListId, snapshot.circuitListId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
snapshot.equipmentIdentifier
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (duplicateIdentifier) {
|
||||
throw new Error(
|
||||
"Duplicate equipmentIdentifier in circuit list."
|
||||
);
|
||||
}
|
||||
assertEquipmentIdentifierAvailable(
|
||||
database,
|
||||
snapshot.circuitListId,
|
||||
snapshot.equipmentIdentifier
|
||||
);
|
||||
|
||||
if (snapshot.deviceRows.length > 0) {
|
||||
const rowIds = snapshot.deviceRows.map((row) => row.id);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
|
|||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||
|
||||
export class DistributionBoardComponentStructureProjectCommandRepository
|
||||
implements DistributionBoardComponentStructureProjectCommandStore
|
||||
|
|
@ -94,6 +95,11 @@ export class DistributionBoardComponentStructureProjectCommandRepository
|
|||
if (existing) {
|
||||
throw new Error("Distribution-board component id already exists.");
|
||||
}
|
||||
assertEquipmentIdentifierAvailable(
|
||||
database,
|
||||
snapshot.component.circuitListId,
|
||||
snapshot.component.equipmentIdentifier
|
||||
);
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(snapshot.component)
|
||||
|
|
@ -187,6 +193,17 @@ export class DistributionBoardComponentStructureProjectCommandRepository
|
|||
"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
|
||||
.update(distributionBoardComponents)
|
||||
.set(target.component)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
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,7 +212,9 @@ function replaceExternalState(
|
|||
if (target.roomMappings.length) {
|
||||
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||
}
|
||||
database.insert(externalModelObjects).values(target.objects).run();
|
||||
if (target.objects.length) {
|
||||
database.insert(externalModelObjects).values(target.objects).run();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCanonicalBase64(value: string) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||
|
||||
export class ExternalObjectNewCircuitProjectCommandRepository
|
||||
implements ExternalObjectNewCircuitProjectCommandStore
|
||||
|
|
@ -90,12 +91,11 @@ export class ExternalObjectNewCircuitProjectCommandRepository
|
|||
.where(eq(circuits.id, circuit.id)).get()) {
|
||||
throw new Error("External circuit id already exists.");
|
||||
}
|
||||
if (database.select({ id: circuits.id }).from(circuits).where(and(
|
||||
eq(circuits.circuitListId, circuit.circuitListId),
|
||||
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
|
||||
)).get()) {
|
||||
throw new Error("Duplicate equipmentIdentifier in circuit list.");
|
||||
}
|
||||
assertEquipmentIdentifierAvailable(
|
||||
database,
|
||||
circuit.circuitListId,
|
||||
circuit.equipmentIdentifier
|
||||
);
|
||||
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, row.id)).get()) {
|
||||
throw new Error("External device-row id already exists.");
|
||||
|
|
|
|||
|
|
@ -1,35 +1,38 @@
|
|||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { circuits } from "./circuits.js";
|
||||
import { projectDevices } from "./project-devices.js";
|
||||
import { rooms } from "./rooms.js";
|
||||
|
||||
export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
|
||||
id: text("id").primaryKey(),
|
||||
circuitId: text("circuit_id")
|
||||
.notNull()
|
||||
.references(() => circuits.id, { onDelete: "cascade" }),
|
||||
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
name: text("name").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
phaseType: text("phase_type"),
|
||||
connectionKind: text("connection_kind"),
|
||||
costGroup: text("cost_group"),
|
||||
category: text("category"),
|
||||
level: text("level"),
|
||||
roomId: text("room_id").references(() => rooms.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
roomNumberSnapshot: text("room_number_snapshot"),
|
||||
roomNameSnapshot: text("room_name_snapshot"),
|
||||
quantity: integer("quantity").notNull(),
|
||||
manualQuantity: integer("manual_quantity").notNull().default(0),
|
||||
powerPerUnit: real("power_per_unit").notNull(),
|
||||
simultaneityFactor: real("simultaneity_factor").notNull(),
|
||||
cosPhi: real("cos_phi"),
|
||||
remark: text("remark"),
|
||||
overriddenFields: text("overridden_fields"),
|
||||
});
|
||||
|
||||
export const circuitDeviceRows = sqliteTable(
|
||||
"circuit_device_rows",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
circuitId: text("circuit_id")
|
||||
.notNull()
|
||||
.references(() => circuits.id, { onDelete: "cascade" }),
|
||||
linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
name: text("name").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
phaseType: text("phase_type"),
|
||||
connectionKind: text("connection_kind"),
|
||||
costGroup: text("cost_group"),
|
||||
category: text("category"),
|
||||
level: text("level"),
|
||||
roomId: text("room_id").references(() => rooms.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
roomNumberSnapshot: text("room_number_snapshot"),
|
||||
roomNameSnapshot: text("room_name_snapshot"),
|
||||
quantity: integer("quantity").notNull(),
|
||||
manualQuantity: integer("manual_quantity").notNull().default(0),
|
||||
powerPerUnit: real("power_per_unit").notNull(),
|
||||
simultaneityFactor: real("simultaneity_factor").notNull(),
|
||||
cosPhi: real("cos_phi"),
|
||||
remark: text("remark"),
|
||||
overriddenFields: text("overridden_fields"),
|
||||
},
|
||||
(table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
import { circuitSections } from "./circuit-sections.js";
|
||||
|
||||
|
|
@ -26,6 +26,9 @@ export const circuits = sqliteTable(
|
|||
isReserve: integer("is_reserve").notNull().default(0),
|
||||
remark: text("remark"),
|
||||
},
|
||||
(table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)]
|
||||
(table) => [
|
||||
unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier),
|
||||
index("circuits_section_id_idx").on(table.sectionId),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,9 @@ function assertCircuitDeviceRowUpdateFieldValue(
|
|||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
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;
|
||||
}
|
||||
if (field === "cosPhi") {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ export function assertCircuitDeviceRowInsertProjectCommand(
|
|||
row.simultaneityFactor,
|
||||
"row.simultaneityFactor"
|
||||
);
|
||||
if (row.simultaneityFactor > 1) {
|
||||
throw new Error("row.simultaneityFactor must not exceed 1.");
|
||||
}
|
||||
if (row.cosPhi !== null) {
|
||||
assertFiniteNumber(row.cosPhi, "row.cosPhi");
|
||||
if (row.cosPhi <= 0) {
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
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,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand(
|
|||
if (target !== null) {
|
||||
assertCircuitProtectionSnapshot(target, circuitId);
|
||||
}
|
||||
if (JSON.stringify(expected) === JSON.stringify(target)) {
|
||||
if (circuitProtectionSnapshotsEqual(expected, target)) {
|
||||
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(
|
||||
value: unknown,
|
||||
circuitId: string
|
||||
) {
|
||||
): asserts value is CircuitProtectionSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 7 ||
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
export interface CircuitSection {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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(),
|
||||
manualQuantity: finiteNumberSchema.nonnegative(),
|
||||
powerPerUnit: finiteNumberSchema.nonnegative(),
|
||||
simultaneityFactor: finiteNumberSchema.nonnegative(),
|
||||
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||
cosPhi: finiteNumberSchema.positive().nullable(),
|
||||
remark: nullableStringSchema,
|
||||
overriddenFields: nullableStringSchema,
|
||||
|
|
|
|||
|
|
@ -268,6 +268,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||
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] =
|
||||
useState<StructureComponentEditorIntent | null>(null);
|
||||
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
||||
|
|
@ -723,6 +727,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
);
|
||||
}, [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(
|
||||
() => data?.sections.flatMap((section) => section.circuits) ?? [],
|
||||
[data]
|
||||
|
|
@ -1046,6 +1071,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
|
||||
// Runs a normal command. The server records it in project-wide history.
|
||||
async function runCommand(command: HistoryCommand) {
|
||||
if (commandInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
commandInFlightRef.current = true;
|
||||
try {
|
||||
setError(null);
|
||||
setIsSaving(true);
|
||||
|
|
@ -1056,6 +1085,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
await loadTree({ showLoading: false });
|
||||
setError(message);
|
||||
} finally {
|
||||
commandInFlightRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1063,6 +1093,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
// Applies the next eligible project-wide history operation. Selection is only
|
||||
// a best-effort local hint; command eligibility and data changes stay server-owned.
|
||||
async function applyHistory(mode: "undo" | "redo") {
|
||||
if (commandInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
commandInFlightRef.current = true;
|
||||
try {
|
||||
setError(null);
|
||||
setHistoryBusy(true);
|
||||
|
|
@ -1088,6 +1122,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
await loadTree({ showLoading: false });
|
||||
setError(message);
|
||||
} finally {
|
||||
commandInFlightRef.current = false;
|
||||
setIsSaving(false);
|
||||
setHistoryBusy(false);
|
||||
}
|
||||
|
|
@ -1744,7 +1779,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
if (!section) {
|
||||
throw new Error("Bereich wurde nicht gefunden.");
|
||||
}
|
||||
const next = await getNextCircuitIdentifier(sectionId);
|
||||
const next = await getNextCircuitIdentifier(projectId, sectionId);
|
||||
const sortOrder =
|
||||
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||
const isDeviceField = deviceFieldKeys.has(key);
|
||||
|
|
@ -1933,7 +1968,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
await runCommand({
|
||||
label: "Stromkreis hinzufügen",
|
||||
redo: async () => {
|
||||
const next = await getNextCircuitIdentifier(sectionId);
|
||||
const next = await getNextCircuitIdentifier(projectId, sectionId);
|
||||
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
|
||||
const circuit = createCircuitSnapshot({
|
||||
sectionId,
|
||||
|
|
@ -2127,7 +2162,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
if (!section) {
|
||||
throw new Error("Der Zielbereich ist ungültig.");
|
||||
}
|
||||
const next = await getNextCircuitIdentifier(sectionId);
|
||||
const next = await getNextCircuitIdentifier(projectId, sectionId);
|
||||
const sortOrder =
|
||||
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||
const circuit = createCircuitSnapshot(
|
||||
|
|
@ -2538,7 +2573,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
await runCommand({
|
||||
label: newCircuitLabel,
|
||||
redo: async () => {
|
||||
const next = await getNextCircuitIdentifier(intent.sectionId);
|
||||
const next = await getNextCircuitIdentifier(projectId, intent.sectionId);
|
||||
const sortOrder =
|
||||
intent.targetCircuitId && intent.placement
|
||||
? getAdjacentInsertionSortOrder(
|
||||
|
|
@ -3699,6 +3734,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!buildCircuitGroupReorderAssignments(
|
||||
data.sections,
|
||||
section.id,
|
||||
|
|
@ -3716,6 +3752,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!buildCircuitGroupReorderAssignments(
|
||||
data.sections,
|
||||
section.id,
|
||||
|
|
@ -3733,6 +3770,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
isSaving ||
|
||||
hasActiveSortOrFilter ||
|
||||
!section.category ||
|
||||
!canRenumberCircuitGroups(
|
||||
|
|
@ -3758,6 +3796,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={isSaving}
|
||||
title={
|
||||
canDeleteCircuitGroup(section)
|
||||
? "Leere Stromkreisgruppe entfernen"
|
||||
|
|
@ -3815,13 +3854,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
>
|
||||
Gruppen-FI hinzufügen
|
||||
</button>
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={isSaving}
|
||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||
>
|
||||
Stromkreis hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={hasActiveSortOrFilter}
|
||||
disabled={hasActiveSortOrFilter || isSaving}
|
||||
onClick={() => void handleRenumberSection(section.id)}
|
||||
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
|
||||
>
|
||||
|
|
@ -4426,6 +4470,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={isSaving}
|
||||
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
||||
>
|
||||
Manuelles Gerät hinzufügen
|
||||
|
|
@ -4433,6 +4478,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={isSaving}
|
||||
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
||||
>
|
||||
Stromkreis löschen
|
||||
|
|
@ -4440,7 +4486,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
</>
|
||||
) : null}
|
||||
{row.device ? (
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
||||
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
||||
Gerät löschen
|
||||
</button>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { type FormEvent, type ReactNode } from "react";
|
||||
import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface FormModalProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -14,6 +14,9 @@ interface FormModalProps {
|
|||
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({
|
||||
children,
|
||||
description,
|
||||
|
|
@ -25,11 +28,56 @@ export function FormModal({
|
|||
submitLabel,
|
||||
title,
|
||||
}: 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 (
|
||||
<>
|
||||
<div
|
||||
aria-modal="true"
|
||||
className="modal fade show d-block"
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
export interface ProjectSettingsInput {
|
||||
name: string;
|
||||
|
|
@ -131,319 +132,278 @@ export function ProjectSettingsModal({
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-labelledby="project-settings-title"
|
||||
aria-modal="true"
|
||||
className="modal fade show d-block"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form className="modal-content" onSubmit={handleSubmit}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title fs-5" id="project-settings-title">
|
||||
Projekteinstellungen
|
||||
</h2>
|
||||
<p className="text-secondary small mb-0">
|
||||
Stammdaten und elektrische Standardwerte des Projekts
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Schließen"
|
||||
className="btn-close"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="project-name">
|
||||
Projektname
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="project-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="internal-project-number">
|
||||
Projektnummer intern
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="internal-project-number"
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setInternalProjectNumber(event.target.value)
|
||||
}
|
||||
value={internalProjectNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="external-project-number">
|
||||
Projektnummer extern
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="external-project-number"
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setExternalProjectNumber(event.target.value)
|
||||
}
|
||||
value={externalProjectNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<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">
|
||||
<FormModal
|
||||
description="Stammdaten und elektrische Standardwerte des Projekts"
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!isValid}
|
||||
submitLabel="Einstellungen speichern"
|
||||
title="Projekteinstellungen"
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="project-name">
|
||||
Projektname
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="project-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="internal-project-number">
|
||||
Projektnummer intern
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="internal-project-number"
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setInternalProjectNumber(event.target.value)
|
||||
}
|
||||
value={internalProjectNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="external-project-number">
|
||||
Projektnummer extern
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="external-project-number"
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setExternalProjectNumber(event.target.value)
|
||||
}
|
||||
value={externalProjectNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<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={isPublicBuilding}
|
||||
checked={enabledDistributionBoardSupplyTypes.includes(
|
||||
supplyType
|
||||
)}
|
||||
className="form-check-input"
|
||||
id="public-building"
|
||||
onChange={(event) =>
|
||||
setIsPublicBuilding(event.target.checked)
|
||||
}
|
||||
disabled={usedDistributionBoardSupplyTypes.includes(
|
||||
supplyType
|
||||
)}
|
||||
onChange={() => toggleSupplyType(supplyType)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="form-check-label">
|
||||
Öffentliches Gebäude
|
||||
{distributionBoardSupplyTypeLabels[supplyType]}
|
||||
{usedDistributionBoardSupplyTypes.includes(
|
||||
supplyType
|
||||
)
|
||||
? " (in Verwendung)"
|
||||
: ""}
|
||||
</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>
|
||||
{enabledDistributionBoardSupplyTypes.length === 0 ? (
|
||||
<div className="text-danger small mt-2">
|
||||
Mindestens eine Netzart muss aktiviert sein.
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={isSaving || !isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : 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">
|
||||
<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 className="modal-backdrop fade show" />
|
||||
</>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -688,9 +688,9 @@ export function deleteCircuitCommand(
|
|||
);
|
||||
}
|
||||
|
||||
export function getNextCircuitIdentifier(sectionId: string) {
|
||||
export function getNextCircuitIdentifier(projectId: string, sectionId: string) {
|
||||
return request<{ sectionId: string; nextIdentifier: string }>(
|
||||
`/api/circuit-sections/${sectionId}/next-identifier`
|
||||
`/api/projects/${projectId}/circuit-sections/${sectionId}/next-identifier`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -396,6 +396,16 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
|
|||
if (trimmed === "") {
|
||||
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(",", "."));
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);
|
||||
|
|
|
|||
|
|
@ -169,13 +169,6 @@ function makeVisibleGridRow(
|
|||
return { rowKey, rowType, sectionId, circuit, device, cells };
|
||||
}
|
||||
|
||||
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
|
||||
return buildVisibleGridRowsWithStructure(sections, {
|
||||
headerComponents: [],
|
||||
footerComponents: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVisibleGridRowsWithStructure(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
structure: {
|
||||
|
|
|
|||
|
|
@ -259,22 +259,28 @@ export function buildCircuitGroupRenumberPlan(
|
|||
targetGroupNumber,
|
||||
expectedPrefix,
|
||||
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
|
||||
circuits: group.circuits.map((circuit) => {
|
||||
const circuitNumber = parseCircuitNumber(
|
||||
circuit.equipmentIdentifier,
|
||||
category,
|
||||
group.groupNumber
|
||||
);
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
||||
circuits: [...group.circuits]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map((circuit) => {
|
||||
const circuitNumber = parseCircuitNumber(
|
||||
circuit.equipmentIdentifier,
|
||||
category,
|
||||
targetGroupNumber,
|
||||
circuitNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
group.groupNumber
|
||||
);
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
||||
category,
|
||||
targetGroupNumber,
|
||||
circuitNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
components: group.components.map((component) => ({
|
||||
componentId: component.id,
|
||||
expectedEquipmentIdentifier: component.equipmentIdentifier,
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ export function buildCircuitSectionRenumberAssignments(
|
|||
)
|
||||
)
|
||||
);
|
||||
const orderedCircuits = [...targetSection.circuits].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder || left.id.localeCompare(right.id)
|
||||
);
|
||||
const assignments: CircuitSectionRenumberAssignment[] = [];
|
||||
let suffix = 1;
|
||||
for (const circuit of targetSection.circuits) {
|
||||
for (const circuit of orderedCircuits) {
|
||||
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
|
||||
while (
|
||||
identifiersOutsideSection.has(targetEquipmentIdentifier)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function buildCircuitStructureProjection(
|
|||
component,
|
||||
});
|
||||
}
|
||||
for (const circuit of section.circuits) {
|
||||
for (const circuit of ordered(section.circuits)) {
|
||||
rows.push({
|
||||
rowKey: `circuit-block:${circuit.id}`,
|
||||
rowType: "circuitBlock",
|
||||
|
|
|
|||
|
|
@ -47,10 +47,24 @@ const commandTypeLabels: Record<string, string> = {
|
|||
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
|
||||
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.update": "Geschoss bearbeitet",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
"project-room.insert": "Raum angelegt",
|
||||
"project-room.update": "Raum bearbeitet",
|
||||
"project-room.delete": "Raum entfernt",
|
||||
"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(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import type { Request, Response } from "express";
|
||||
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
|
||||
import {
|
||||
circuitListRepository,
|
||||
circuitSectionRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
|
||||
export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
||||
const { sectionId } = req.params;
|
||||
if (typeof sectionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid sectionId" });
|
||||
const { projectId, sectionId } = req.params;
|
||||
if (typeof projectId !== "string" || typeof sectionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
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 {
|
||||
const nextIdentifier =
|
||||
|
|
|
|||
|
|
@ -34,11 +34,12 @@ export async function updateGlobalDevice(req: Request, res: Response) {
|
|||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
await globalDeviceRepository.update(globalDeviceId, parsed.data);
|
||||
const row = await globalDeviceRepository.findById(globalDeviceId);
|
||||
if (!row) {
|
||||
const existing = await globalDeviceRepository.findById(globalDeviceId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Global device not found" });
|
||||
}
|
||||
await globalDeviceRepository.update(globalDeviceId, parsed.data);
|
||||
const row = await globalDeviceRepository.findById(globalDeviceId);
|
||||
return res.json(row);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import express from "express";
|
||||
import { circuitRouter } from "./routes/circuit.routes.js";
|
||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
|
|
@ -48,7 +47,6 @@ app.get("/health", (_req, res) => {
|
|||
});
|
||||
|
||||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api", circuitRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import {
|
||||
getNextCircuitIdentifier,
|
||||
} from "../controllers/circuit.controller.js";
|
||||
|
||||
export const circuitRouter = Router();
|
||||
|
||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
|
|||
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
|
||||
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
|
||||
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
|
||||
import {
|
||||
getProjectHistory,
|
||||
listProjectRevisions,
|
||||
|
|
@ -95,6 +96,10 @@ projectRouter.delete(
|
|||
);
|
||||
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
||||
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
||||
projectRouter.get(
|
||||
"/:projectId/circuit-sections/:sectionId/next-identifier",
|
||||
getNextCircuitIdentifier
|
||||
);
|
||||
projectRouter.get("/:projectId/floors", listFloorsByProject);
|
||||
projectRouter.post("/:projectId/floors", createFloor);
|
||||
projectRouter.put("/:projectId/floors/:floorId", updateFloor);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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
|
||||
.object({
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
|
|
@ -10,7 +15,7 @@ export const updateExternalCsvConfigurationSchema = z
|
|||
export const previewExternalCsvSchema = z
|
||||
.object({
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
contentBase64: z.string().min(1).max(24_000_000),
|
||||
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -21,7 +26,7 @@ export const applyExternalInitialImportSchema = z.object({
|
|||
expectedConfigurationVersion: z.number().int().positive(),
|
||||
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
contentBase64: z.string().min(1).max(24_000_000),
|
||||
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
||||
sourceName: z.string().trim().min(1).max(200),
|
||||
roomDecisions: z.array(z.object({
|
||||
sourceRoomKey: z.string().trim().min(1),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const createGlobalDeviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
name: z.string().min(1).max(200),
|
||||
displayName: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional(),
|
||||
quantity: z.number().min(0),
|
||||
installedPowerPerUnitKw: z.number().min(0),
|
||||
demandFactor: z.number().min(0).max(1),
|
||||
phaseCount: z.union([z.literal(1), z.literal(3)]),
|
||||
powerFactor: z.number().min(0).max(1).optional(),
|
||||
note: z.string().optional(),
|
||||
note: z.string().max(2000).optional(),
|
||||
}).strict();
|
||||
|
||||
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
|||
import { circuitGroupCategories } from "../constants/circuit-group.js";
|
||||
|
||||
export const createProjectDeviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
connectionKind: z.string().optional(),
|
||||
costGroup: z.string().optional(),
|
||||
name: z.string().min(1).max(200),
|
||||
displayName: z.string().min(1).max(200),
|
||||
connectionKind: z.string().max(100).optional(),
|
||||
costGroup: z.string().max(100).optional(),
|
||||
category: z.enum(circuitGroupCategories),
|
||||
quantity: z.number().min(0),
|
||||
powerPerUnit: z.number().min(0),
|
||||
simultaneityFactor: z.number().min(0).max(1),
|
||||
cosPhi: z.number().min(0).max(1).optional(),
|
||||
remark: z.string().optional(),
|
||||
remark: z.string().max(2000).optional(),
|
||||
}).strict();
|
||||
|
||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const updateProjectSettingsSchema = z
|
|||
export const createDistributionBoardSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
floorId: z.string().trim().min(1).nullable(),
|
||||
supplyType: z.enum(distributionBoardSupplyTypes),
|
||||
})
|
||||
|
|
@ -67,7 +67,7 @@ export const deleteDistributionBoardSchema = z
|
|||
export const createFloorSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -83,8 +83,8 @@ export const createRoomSchema = z
|
|||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
floorId: z.string().trim().min(1).optional(),
|
||||
roomNumber: z.string().trim().min(1),
|
||||
roomName: z.string().trim().min(1),
|
||||
roomNumber: z.string().trim().min(1).max(50),
|
||||
roomName: z.string().trim().min(1).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -92,8 +92,8 @@ export const updateRoomSchema = z
|
|||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
floorId: z.string().trim().min(1).nullable(),
|
||||
roomNumber: z.string().trim().min(1),
|
||||
roomName: z.string().trim().min(1),
|
||||
roomNumber: z.string().trim().min(1).max(50),
|
||||
roomName: z.string().trim().min(1).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue