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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue