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:
Julian Appel 2026-08-06 21:31:16 +02:00
parent fa96be2d42
commit b45dc5002d
48 changed files with 3263 additions and 526 deletions

View file

@ -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}

View file

@ -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}
>

View file

@ -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>
);
}

View file

@ -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`
);
}

View file

@ -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}`);

View file

@ -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: {

View file

@ -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,

View file

@ -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)

View file

@ -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",

View file

@ -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(