forked from jappel/leistungsbilanz-ts
Replace the Revit drawer with the assignment dialog
- RevitAssignmentDialog: objects as room/group/object with multi-select, filters and drag-and-drop or keyboard assignment onto rows, circuits or new circuits; room-wise automatic split under the socket limit; local plan with review of conflicts and limit overruns, one command on apply and re-check after a revision conflict. - Row badge and context menu list linked objects and detach single objects or all; the context menu opens the dialog for a circuit. - Remove the R7 drawer and direct grid drops; update AGENTS.md, API and architecture docs and mark R11-R14 complete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd45a1caeb
commit
191455c745
12 changed files with 1885 additions and 157 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -323,7 +323,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 10 stores the Linear-XLSX configuration and
|
Current snapshot schema version 11 adds the project setting
|
||||||
|
`maxSocketsPerCircuit`; version 10 is upgraded with `null`. Version 10 stores
|
||||||
|
the Linear-XLSX configuration and
|
||||||
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
||||||
versions containing legacy CSV configuration or external CSV state are rejected
|
versions containing legacy CSV configuration or external CSV state are rejected
|
||||||
with a German migration error. Version 9 additionally stores
|
with a German migration error. Version 9 additionally stores
|
||||||
|
|
@ -408,8 +410,22 @@ to preserve the row.
|
||||||
with stable BMK/UUIDs, derived voltage, one external-only row, required default
|
with stable BMK/UUIDs, derived voltage, one external-only row, required default
|
||||||
protection and its external object links. Its history-only inverse requires the
|
protection and its external object links. Its history-only inverse requires the
|
||||||
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
||||||
the BMK. All three external assignment stores share
|
the BMK. All external assignment stores share
|
||||||
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
||||||
|
The editor no longer uses these three commands (they stay executable for
|
||||||
|
stored history): it assigns through the Revit assignment dialog
|
||||||
|
(`revit-assignment-dialog.tsx`, requirements §11). The dialog builds a local
|
||||||
|
plan with drag-and-drop, keyboard assignment and a room-wise automatic split
|
||||||
|
(`src/shared/revit/assignment-plan.ts`, shared with the API); a Revit object is
|
||||||
|
indivisible and always lands on exactly one row and circuit.
|
||||||
|
`external-object.apply-assignment-plan` writes the whole plan (new circuits
|
||||||
|
with BMKs planned once, new rows, row quantities, links) as one revision; its
|
||||||
|
history-only inverse `external-object.revert-assignment-plan` removes only
|
||||||
|
unchanged created structure. Planning-value conflicts and circuits above the
|
||||||
|
project socket limit need explicit confirmation. Only Revit device types with
|
||||||
|
`isSocket` (configuration schema 2; version 1 derives it from `socketCount`
|
||||||
|
rules) count towards `maxSocketsPerCircuit`. The row badge lists linked objects
|
||||||
|
and detaches single objects or all of them.
|
||||||
The current read-only
|
The current read-only
|
||||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||||
projection resolves the board through the owned CircuitList and returns compact
|
projection resolves the board through the owned CircuitList and returns compact
|
||||||
|
|
@ -452,6 +468,9 @@ Schema version 3 additionally carries the print cover fields `siteAddress`,
|
||||||
`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2
|
`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2
|
||||||
(without them) and baseline version 1 stay executable and keep the current
|
(without them) and baseline version 1 stay executable and keep the current
|
||||||
cover values. The PUT route accepts the cover fields optionally.
|
cover values. The PUT route accepts the cover fields optionally.
|
||||||
|
Schema version 4 adds the client-specific `maxSocketsPerCircuit` (migration
|
||||||
|
`0018`, positive integer or `null` = no limit); versions 1 to 3 keep the
|
||||||
|
current value. The PUT route accepts it optionally.
|
||||||
The public-building flag is the future sizing input for requiring halogen-free
|
The public-building flag is the future sizing input for requiring halogen-free
|
||||||
cables and lines. The system catalog is `AV`, `SV`, `EV`,
|
cables and lines. The system catalog is `AV`, `SV`, `EV`,
|
||||||
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||||
|
|
|
||||||
|
|
@ -500,9 +500,17 @@ Full request example and calculation rules: `docs/cable-sizing-module.md`.
|
||||||
additions, source changes, unchanged and missing objects. Apply requires the
|
additions, source changes, unchanged and missing objects. Apply requires the
|
||||||
preview hash and source decisions and updates linked row quantities atomically.
|
preview hash and source decisions and updates linked row quantities atomically.
|
||||||
- `GET|PUT .../external-model/objects` loads one compact project list and
|
- `GET|PUT .../external-model/objects` loads one compact project list and
|
||||||
applies revision-safe multi-object planning changes.
|
applies revision-safe multi-object planning changes. The list also carries
|
||||||
|
`maxSocketsPerCircuit` and the `socketDeviceTypeIds` of the configuration.
|
||||||
|
- `POST .../objects/assignment-plan` applies one plan of the Revit assignment
|
||||||
|
dialog: `{ expectedRevision, circuitListId, newCircuits: [{ key, sectionId }],
|
||||||
|
rows: [{ kind: "existing-row" | "new-row" | "new-row-in-new-circuit", … ,
|
||||||
|
objectIds }], confirmedConflictObjectIds, confirmedOverLimitCircuitKeys }`
|
||||||
|
(strict schema). New-circuit BMKs follow the request order; circuits above
|
||||||
|
the project socket limit are confirmed by circuit id or `new:<key>`.
|
||||||
- `POST .../objects/assign-row|assign-new-row|assign-new-circuit|detach-row`
|
- `POST .../objects/assign-row|assign-new-row|assign-new-circuit|detach-row`
|
||||||
changes external-object row assignments through persistent commands.
|
remain available; the editor uses only `detach-row` (single objects or all
|
||||||
|
objects of one row).
|
||||||
- `POST .../objects/delete-missing` requires `confirmed: true`, deletes only
|
- `POST .../objects/delete-missing` requires `confirmed: true`, deletes only
|
||||||
objects marked missing and adjusts linked row quantities in the same command.
|
objects marked missing and adjusts linked row quantities in the same command.
|
||||||
- `POST .../external-model/return-workbook` creates the structure-preserving
|
- `POST .../external-model/return-workbook` creates the structure-preserving
|
||||||
|
|
|
||||||
|
|
@ -736,6 +736,36 @@ SQLite-Blöcken. Der synthetische Abnahmetest verarbeitet 5.000 Objekte; Details
|
||||||
und Messwerte stehen in
|
und Messwerte stehen in
|
||||||
[R10-Abnahme](spec/revit-excel-r10-validation.md).
|
[R10-Abnahme](spec/revit-excel-r10-validation.md).
|
||||||
|
|
||||||
|
## Revit-Zuordnungsdialog und Steckdosen-Höchstwert (R11–R14)
|
||||||
|
|
||||||
|
Der Stromkreislisteneditor ordnet Revit-Objekte über den Dialog
|
||||||
|
`RevitAssignmentDialog` zu (Toolbar „Revit-Objekte zuordnen“ oder Kontextmenü
|
||||||
|
eines Stromkreises). Der frühere rechte Drawer und das Ziehen direkt ins Grid
|
||||||
|
sind entfernt. Links stehen die zuordenbaren Objekte des Verteilers als Raum →
|
||||||
|
Gruppe → Objekt mit Mehrfachauswahl und Filtern, rechts die Stromkreise mit
|
||||||
|
Steckdosenbelegung. Drag-and-drop, „Zuweisen an …“ und „Automatisch aufteilen
|
||||||
|
…“ ändern nur einen lokalen Plan.
|
||||||
|
|
||||||
|
Die Planungsregeln liegen importfrei in `src/shared/revit/assignment-plan.ts`
|
||||||
|
und werden von Dialog und API gemeinsam genutzt: Zeilenbildung je Raum,
|
||||||
|
Auswahlkenner, Geräteart, Kategorie, ProjectDevice und Planungswerten,
|
||||||
|
Konfliktliste, Belegung und die raumweise Aufteilung ohne Teilung eines
|
||||||
|
Objekts. `POST /api/projects/:projectId/external-model/objects/assignment-plan`
|
||||||
|
prüft den Plan erneut (strikte zod-Eingabe), verlangt die ausdrückliche
|
||||||
|
Bestätigung von Planungswert-Konflikten und Höchstwert-Überschreitungen und
|
||||||
|
schreibt ihn als `external-object.apply-assignment-plan`: neue Stromkreise mit
|
||||||
|
einmal geplanten BMKs und Standardschutz, neue Rows, Mengenänderungen und alle
|
||||||
|
Objektlinks in einer Revision. Die history-only Inverse
|
||||||
|
`external-object.revert-assignment-plan` entfernt nur unverändert gebliebene
|
||||||
|
erzeugte Struktur.
|
||||||
|
|
||||||
|
Der Höchstwert `projects.max_sockets_per_circuit` ist eine allgemeine
|
||||||
|
Projekteinstellung (Migration `0018`, `project.update-settings` Version 4,
|
||||||
|
Snapshot-Schema 11). Welche Revit-Gerätearten als Steckdose zählen, legt
|
||||||
|
`deviceTypes[].isSocket` der Revit-Konfiguration Version 2 fest; Version 1
|
||||||
|
leitet es aus `socketCount`-Familienregeln ab
|
||||||
|
(`src/shared/revit/socket-device-types.ts`).
|
||||||
|
|
||||||
## Historische CSV-Unterlagen
|
## Historische CSV-Unterlagen
|
||||||
|
|
||||||
Der vor R3 vorhandene CSV-/IfcGUID-Laufzeitpfad ist entfernt. Seine damalige
|
Der vor R3 vorhandene CSV-/IfcGUID-Laufzeitpfad ist entfernt. Seine damalige
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
||||||
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
|
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
|
||||||
| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. |
|
| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. |
|
||||||
| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. |
|
| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. |
|
||||||
| R11 | geplant | Projekteinstellung `maxSocketsPerCircuit` und Geräteart-Merkmal `isSocket` samt Hochstufung und Einstellungs-UI. |
|
| R11 | abgeschlossen | Projekteinstellung `maxSocketsPerCircuit` und Geräteart-Merkmal `isSocket` samt Hochstufung und Einstellungs-UI. |
|
||||||
| R12 | geplant | Reines Planungsmodul für Row-Bildung, Belegung, Konflikte und raumweise Aufteilung. |
|
| R12 | abgeschlossen | Reines Planungsmodul für Row-Bildung, Belegung, Konflikte und raumweise Aufteilung. |
|
||||||
| R13 | geplant | Atomarer Command `external-object.apply-assignment-plan` mit Route und exakter Inverse. |
|
| R13 | abgeschlossen | Atomarer Command `external-object.apply-assignment-plan` mit Route und exakter Inverse. |
|
||||||
| R14 | geplant | Zuordnungsdialog mit Drag-and-drop und Plan, Row-Objektliste mit Einzel-Lösen, Entfernen des R7-Drawers. |
|
| R14 | abgeschlossen | Zuordnungsdialog mit Drag-and-drop und Plan, Row-Objektliste mit Einzel-Lösen, Entfernen des R7-Drawers. |
|
||||||
|
|
||||||
## R0 – Audit
|
## R0 – Audit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3120,3 +3120,219 @@ button.structure-entry-main:focus-visible {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Revit assignment dialog (requirements §11.2): objects left, circuits right. */
|
||||||
|
.revit-assign {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-scroll {
|
||||||
|
height: calc(100vh - 22rem);
|
||||||
|
min-height: 16rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-filters,
|
||||||
|
.revit-assign-footer,
|
||||||
|
.revit-assign-split {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-filters {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-filters input[type="search"],
|
||||||
|
.revit-assign-filters .form-control {
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-filters .form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--panel-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-split {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-limit {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-room + .revit-assign-room {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-room-header,
|
||||||
|
.revit-assign-group-header,
|
||||||
|
.revit-assign-object,
|
||||||
|
.revit-assign-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-room-header {
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-group {
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-group-header {
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-object {
|
||||||
|
margin: 0 0 0 1.25rem;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-object.is-selected {
|
||||||
|
background: var(--accent-blue-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-family {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-section + .revit-assign-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-circuit {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-circuit.is-initial {
|
||||||
|
border-color: var(--accent-blue-strong-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-circuit.is-new {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--accent-blue-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-target {
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-target[data-drop-state="valid"] {
|
||||||
|
outline: 1px dashed var(--accent-blue-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-target[data-drop-state="over"] {
|
||||||
|
outline: 2px solid var(--accent-blue-drop-border);
|
||||||
|
background: var(--accent-blue-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-target[data-drop-state="invalid"] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-occupancy {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-occupancy.is-over {
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-rows,
|
||||||
|
.revit-assign-planned {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.4rem 0.3rem 1.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-planned-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-left: 3px solid var(--accent-blue);
|
||||||
|
background: var(--accent-blue-soft-bg);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-planned-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-new {
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--panel-border-strong);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revit-assign-review {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.revit-assign {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
FilterX,
|
FilterX,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Funnel,
|
Funnel,
|
||||||
|
Link2,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
ListX,
|
ListX,
|
||||||
|
|
@ -126,9 +127,6 @@ import {
|
||||||
deleteCircuitGroupCommand,
|
deleteCircuitGroupCommand,
|
||||||
deleteCircuitGroupSubtreeCommand,
|
deleteCircuitGroupSubtreeCommand,
|
||||||
deleteDistributionBoardComponentCommand,
|
deleteDistributionBoardComponentCommand,
|
||||||
assignExternalProjectObjectsToRow,
|
|
||||||
assignExternalProjectObjectsToNewRow,
|
|
||||||
assignExternalProjectObjectsToNewCircuit,
|
|
||||||
detachExternalProjectObjectsFromRow,
|
detachExternalProjectObjectsFromRow,
|
||||||
getExternalProjectObjects,
|
getExternalProjectObjects,
|
||||||
getCircuitTree,
|
getCircuitTree,
|
||||||
|
|
@ -192,6 +190,9 @@ import { CircuitGroupModal } from "./circuit-group-modal";
|
||||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||||
import { GridContextMenu, type GridContextMenuEntry } from "./grid-context-menu";
|
import { GridContextMenu, type GridContextMenuEntry } from "./grid-context-menu";
|
||||||
import { CableSizingModal } from "./cable-sizing-modal";
|
import { CableSizingModal } from "./cable-sizing-modal";
|
||||||
|
import { RevitAssignmentDialog } from "./revit-assignment-dialog";
|
||||||
|
import { RevitLinkedObjectsDialog } from "./revit-linked-objects-dialog";
|
||||||
|
import { isAssignableObject } from "../utils/revit-assignment-model";
|
||||||
import {
|
import {
|
||||||
circuitGroupCategoryLabels,
|
circuitGroupCategoryLabels,
|
||||||
type CircuitGroupCategory,
|
type CircuitGroupCategory,
|
||||||
|
|
@ -365,8 +366,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
useState<CircuitTreeCircuitDto | null>(null);
|
useState<CircuitTreeCircuitDto | null>(null);
|
||||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
const [externalObjects, setExternalObjects] = useState<ExternalProjectObjectsDto | null>(null);
|
const [externalObjects, setExternalObjects] = useState<ExternalProjectObjectsDto | null>(null);
|
||||||
const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false);
|
// Revit assignment dialog (requirements §11.2); null while closed.
|
||||||
const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState<string[]>([]);
|
const [revitAssignment, setRevitAssignment] = useState<{
|
||||||
|
initialCircuitId: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
// Device row whose linked Revit objects are listed; null while closed.
|
||||||
|
const [linkedObjectsRowId, setLinkedObjectsRowId] = useState<string | null>(null);
|
||||||
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
||||||
|
|
@ -418,26 +423,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
|
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
|
||||||
[orderedColumns, visibleColumnKeys]
|
[orderedColumns, visibleColumnKeys]
|
||||||
);
|
);
|
||||||
const externalObjectGroups = useMemo(() => {
|
const assignableExternalObjectCount = useMemo(
|
||||||
if (!data || !externalObjects) return [];
|
() =>
|
||||||
const groups = new Map<string, typeof externalObjects.objects>();
|
data && externalObjects
|
||||||
for (const object of externalObjects.objects) {
|
? externalObjects.objects.filter((object) =>
|
||||||
if (object.presenceStatus !== "present" || object.circuitDeviceRowId !== null || object.distributionBoardId !== data.distributionBoardId) continue;
|
isAssignableObject(object, data.distributionBoardId)
|
||||||
const key = JSON.stringify([
|
).length
|
||||||
object.roomId, object.selectionMarker, object.deviceTypeId,
|
: 0,
|
||||||
object.planningValues.category, object.linkedProjectDeviceId,
|
[data, externalObjects]
|
||||||
object.planningValues,
|
);
|
||||||
]);
|
|
||||||
const entries = groups.get(key) ?? [];
|
|
||||||
entries.push(object);
|
|
||||||
groups.set(key, entries);
|
|
||||||
}
|
|
||||||
return [...groups.values()].map((objects) => ({
|
|
||||||
key: objects.map((object) => object.id).sort().join("|"),
|
|
||||||
objects,
|
|
||||||
quantity: objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
|
|
||||||
}));
|
|
||||||
}, [data, externalObjects]);
|
|
||||||
|
|
||||||
async function loadTree(options?: { showLoading?: boolean }) {
|
async function loadTree(options?: { showLoading?: boolean }) {
|
||||||
const showLoading = options?.showLoading ?? false;
|
const showLoading = options?.showLoading ?? false;
|
||||||
|
|
@ -2549,6 +2543,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
void handleAddManualDevice(circuit, row.sectionId, device?.id),
|
void handleAddManualDevice(circuit, row.sectionId, device?.id),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
if (externalObjects && externalObjects.objects.length > 0) {
|
||||||
|
entries.push({
|
||||||
|
kind: "action",
|
||||||
|
label: "Revit-Objekte zuordnen …",
|
||||||
|
icon: Link2,
|
||||||
|
disabled: busy,
|
||||||
|
onSelect: () => setRevitAssignment({ initialCircuitId: circuit.id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (device && (device.externalObjectCount ?? 0) > 0) {
|
||||||
|
entries.push({
|
||||||
|
kind: "action",
|
||||||
|
label: `Revit-Objekte anzeigen (${device.externalObjectCount})`,
|
||||||
|
icon: Link2,
|
||||||
|
disabled: busy,
|
||||||
|
onSelect: () => setLinkedObjectsRowId(device.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (device || (circuit && isCircuitRow(row))) {
|
if (device || (circuit && isCircuitRow(row))) {
|
||||||
entries.push({ kind: "separator" });
|
entries.push({ kind: "separator" });
|
||||||
|
|
@ -3521,51 +3533,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
await handleDeleteCircuit(intent.circuitId);
|
await handleDeleteCircuit(intent.circuitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAssignExternalObjects(rowId: string, objectIds: string[]) {
|
function describeDeviceRow(rowId: string) {
|
||||||
if (!confirm(`${objectIds.length} Revit-Objekt(e) dieser Gerätezeile zuordnen?\n\nAbweichende Planungswerte werden ausdrücklich zugunsten der Gerätezeile bestätigt.`)) return;
|
for (const section of data?.sections ?? []) {
|
||||||
await runCommand({
|
for (const circuit of section.circuits) {
|
||||||
label: "Revit-Objekte zuordnen",
|
const deviceRow = circuit.deviceRows.find((entry) => entry.id === rowId);
|
||||||
redo: async () => {
|
if (deviceRow) {
|
||||||
const result = await assignExternalProjectObjectsToRow(
|
return `${circuit.equipmentIdentifier} · ${deviceRow.displayName || deviceRow.name}`;
|
||||||
projectId,
|
}
|
||||||
getExpectedProjectRevision(),
|
}
|
||||||
objectIds,
|
}
|
||||||
rowId,
|
return "Gerätezeile";
|
||||||
objectIds
|
|
||||||
);
|
|
||||||
applyProjectCommandResult(result);
|
|
||||||
setDraggingExternalObjectIds([]);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAssignExternalObjectsToNewRow(circuitId: string, objectIds: string[]) {
|
async function handleDetachExternalObjects(objectIds: string[]) {
|
||||||
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neue Gerätezeile anlegen?`)) return;
|
if (objectIds.length === 0) return;
|
||||||
await runCommand({ label: "Revit-Objekte als Gerätezeile zuordnen", redo: async () => {
|
|
||||||
const result = await assignExternalProjectObjectsToNewRow(projectId, getExpectedProjectRevision(), objectIds, circuitId);
|
|
||||||
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAssignExternalObjectsToNewCircuit(sectionId: string, objectIds: string[]) {
|
|
||||||
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neuen Stromkreis anlegen?`)) return;
|
|
||||||
await runCommand({ label: "Revit-Objekte als Stromkreis zuordnen", redo: async () => {
|
|
||||||
const result = await assignExternalProjectObjectsToNewCircuit(projectId, getExpectedProjectRevision(), objectIds, sectionId);
|
|
||||||
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDetachExternalObjects(rowId: string) {
|
|
||||||
const objectIds = externalObjects?.objects
|
|
||||||
.filter((object) => object.circuitDeviceRowId === rowId)
|
|
||||||
.map((object) => object.id) ?? [];
|
|
||||||
if (objectIds.length === 0 || !confirm(`${objectIds.length} Revit-Objekt(e) lösen?\n\nDie aktuellen Werte der Gerätezeile werden als Planungswerte und Overrides übernommen.`)) return;
|
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Revit-Objekte lösen",
|
label: "Revit-Objekte lösen",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
|
const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
|
||||||
applyProjectCommandResult(result);
|
applyProjectCommandResult(result);
|
||||||
|
setLinkedObjectsRowId(null);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -3903,6 +3890,33 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
onSave={handleSaveCircuitProtection}
|
onSave={handleSaveCircuitProtection}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{revitAssignment && externalObjects ? (
|
||||||
|
<RevitAssignmentDialog
|
||||||
|
externalObjects={externalObjects}
|
||||||
|
getExpectedRevision={getExpectedProjectRevision}
|
||||||
|
initialCircuitId={revitAssignment.initialCircuitId}
|
||||||
|
onApplied={async (result) => {
|
||||||
|
applyProjectCommandResult(result);
|
||||||
|
setRevitAssignment(null);
|
||||||
|
await loadTree({ showLoading: false });
|
||||||
|
}}
|
||||||
|
onClose={() => setRevitAssignment(null)}
|
||||||
|
onReload={() => loadTree({ showLoading: false })}
|
||||||
|
projectId={projectId}
|
||||||
|
tree={data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{linkedObjectsRowId && externalObjects ? (
|
||||||
|
<RevitLinkedObjectsDialog
|
||||||
|
isSaving={isSaving}
|
||||||
|
objects={externalObjects.objects.filter(
|
||||||
|
(object) => object.circuitDeviceRowId === linkedObjectsRowId
|
||||||
|
)}
|
||||||
|
onClose={() => setLinkedObjectsRowId(null)}
|
||||||
|
onDetach={handleDetachExternalObjects}
|
||||||
|
rowLabel={describeDeviceRow(linkedObjectsRowId)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{cableSizingEditorCircuit ? (
|
{cableSizingEditorCircuit ? (
|
||||||
<CableSizingModal
|
<CableSizingModal
|
||||||
circuit={cableSizingEditorCircuit}
|
circuit={cableSizingEditorCircuit}
|
||||||
|
|
@ -3990,15 +4004,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
}
|
}
|
||||||
showLabel
|
showLabel
|
||||||
/>
|
/>
|
||||||
|
{externalObjects && externalObjects.objects.length > 0 ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
ariaControls="revit-object-drawer"
|
|
||||||
ariaExpanded={isRevitDrawerOpen}
|
|
||||||
className="project-device-drawer-toggle"
|
className="project-device-drawer-toggle"
|
||||||
icon={isRevitDrawerOpen ? PanelRightClose : PanelRightOpen}
|
disabled={isSaving || historyBusy}
|
||||||
label={isRevitDrawerOpen ? "Revit-Objekte schließen" : `Revit-Objekte öffnen (${externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)})`}
|
icon={Link2}
|
||||||
onClick={() => setIsRevitDrawerOpen((current) => !current)}
|
label={`Revit-Objekte zuordnen (${assignableExternalObjectCount})`}
|
||||||
|
onClick={() => setRevitAssignment({ initialCircuitId: null })}
|
||||||
showLabel
|
showLabel
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<IconButton
|
<IconButton
|
||||||
ariaControls="project-device-drawer"
|
ariaControls="project-device-drawer"
|
||||||
ariaExpanded={isProjectDeviceDrawerOpen}
|
ariaExpanded={isProjectDeviceDrawerOpen}
|
||||||
|
|
@ -4047,62 +4062,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="tree-editor-layout">
|
<div className="tree-editor-layout">
|
||||||
{isRevitDrawerOpen ? (
|
|
||||||
<aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer">
|
|
||||||
<div className="project-device-drawer-header">
|
|
||||||
<div>
|
|
||||||
<h3>Revit-Objekte</h3>
|
|
||||||
<span>{externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)} unzugeordnet</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => setIsRevitDrawerOpen(false)}>Schließen</button>
|
|
||||||
</div>
|
|
||||||
<div className="project-device-list">
|
|
||||||
{externalObjectGroups.map((group) => {
|
|
||||||
const first = group.objects[0]!;
|
|
||||||
return (
|
|
||||||
<details
|
|
||||||
key={group.key}
|
|
||||||
className={`project-device-item ${draggingExternalObjectIds[0] === first.id ? "dragging" : ""}`}
|
|
||||||
>
|
|
||||||
<summary
|
|
||||||
draggable
|
|
||||||
onDragStart={(event) => {
|
|
||||||
const ids = group.objects.map((object) => object.id);
|
|
||||||
setDraggingExternalObjectIds(ids);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify(ids));
|
|
||||||
}}
|
|
||||||
onDragEnd={() => setDraggingExternalObjectIds([])}
|
|
||||||
>
|
|
||||||
<strong>{first.planningValues.displayName || first.selectionMarker || first.familyAndType}</strong>
|
|
||||||
<span>{first.sourceRoomNumber} {first.sourceRoomName}</span>
|
|
||||||
<span>{group.objects.length} Objekt(e), Menge {formatValue(group.quantity, "quantity")}</span>
|
|
||||||
<span>{first.familyAndType}</span>
|
|
||||||
</summary>
|
|
||||||
{group.objects.map((object) => (
|
|
||||||
<button
|
|
||||||
key={object.id}
|
|
||||||
type="button"
|
|
||||||
draggable
|
|
||||||
className="btn btn-sm btn-outline-secondary mt-2 w-100 text-start"
|
|
||||||
onDragStart={(event) => {
|
|
||||||
setDraggingExternalObjectIds([object.id]);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify([object.id]));
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
onDragEnd={() => setDraggingExternalObjectIds([])}
|
|
||||||
>
|
|
||||||
{object.worksharingId}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{externalObjectGroups.length === 0 ? <p className="notice muted">Keine unzugeordneten Revit-Objekte für diesen Verteiler.</p> : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
) : null}
|
|
||||||
{isProjectDeviceDrawerOpen ? (
|
{isProjectDeviceDrawerOpen ? (
|
||||||
<aside
|
<aside
|
||||||
className="project-device-sidebar project-device-drawer"
|
className="project-device-sidebar project-device-drawer"
|
||||||
|
|
@ -4892,11 +4851,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveSectionId(row.sectionId)}
|
onClick={() => setActiveSectionId(row.sectionId)}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (draggingCircuitCount > 0) {
|
if (draggingCircuitCount > 0) {
|
||||||
const valid = isCircuitDropTargetValid(
|
const valid = isCircuitDropTargetValid(
|
||||||
row.sectionId
|
row.sectionId
|
||||||
|
|
@ -5037,21 +4991,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (draggingExternalObjectIds.length > 0 && row.device) {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleAssignExternalObjects(row.device.id, draggingExternalObjectIds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (draggingExternalObjectIds.length > 0 && row.rowType === "placeholder") {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleAssignExternalObjectsToNewCircuit(row.sectionId, draggingExternalObjectIds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (draggingExternalObjectIds.length > 0 && row.circuit) {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleAssignExternalObjectsToNewRow(row.circuit.id, draggingExternalObjectIds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (draggingCircuitCount > 0) {
|
if (draggingCircuitCount > 0) {
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
void handleCircuitReorderDrop(event, {
|
void handleCircuitReorderDrop(event, {
|
||||||
|
|
@ -5451,7 +5390,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
type="button"
|
type="button"
|
||||||
className="badge rounded-pill text-bg-info border-0"
|
className="badge rounded-pill text-bg-info border-0"
|
||||||
title="Verknüpfte Revit-Objekte anzeigen und lösen"
|
title="Verknüpfte Revit-Objekte anzeigen und lösen"
|
||||||
onClick={() => void handleDetachExternalObjects(row.device!.id)}
|
onClick={() => setLinkedObjectsRowId(row.device!.id)}
|
||||||
>
|
>
|
||||||
Revit {row.device.externalObjectCount}
|
Revit {row.device.externalObjectCount}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
896
src/frontend/components/revit-assignment-dialog.tsx
Normal file
896
src/frontend/components/revit-assignment-dialog.tsx
Normal file
|
|
@ -0,0 +1,896 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronRight, Plus, Split, Undo2, X } from "lucide-react";
|
||||||
|
import React, { type DragEvent, type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
assignmentConflictFieldLabels,
|
||||||
|
circuitRefKey,
|
||||||
|
findRowIssues,
|
||||||
|
newRowChoiceKey,
|
||||||
|
objectGroupKey,
|
||||||
|
resolveAssignmentPlan,
|
||||||
|
socketQuantity,
|
||||||
|
splitSocketsByRoom,
|
||||||
|
toAssignmentPlanRequestRows,
|
||||||
|
type AssignmentPlanCircuit,
|
||||||
|
type AssignmentPlanObject,
|
||||||
|
type AssignmentPlanState,
|
||||||
|
type AssignmentTarget,
|
||||||
|
type ResolvedCircuitPlan,
|
||||||
|
type ResolvedRowAssignment,
|
||||||
|
} from "../../shared/revit/assignment-plan";
|
||||||
|
import type {
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
CircuitTreeSectionDto,
|
||||||
|
ExternalProjectObjectsDto,
|
||||||
|
ProjectCommandResultDto,
|
||||||
|
} from "../types";
|
||||||
|
import { ApiRequestError, applyExternalObjectAssignmentPlan } from "../utils/api";
|
||||||
|
import { createClientId } from "../utils/client-id";
|
||||||
|
import {
|
||||||
|
assignObjects,
|
||||||
|
buildAssignablePlanObjects,
|
||||||
|
buildPlanCircuits,
|
||||||
|
buildPlanSections,
|
||||||
|
buildRoomTree,
|
||||||
|
categoriesOf,
|
||||||
|
countObjectsWithoutBoard,
|
||||||
|
flattenRoomTree,
|
||||||
|
formatQuantity,
|
||||||
|
previewNewCircuitIdentifiers,
|
||||||
|
pruneNewCircuits,
|
||||||
|
revalidatePlan,
|
||||||
|
selectRange,
|
||||||
|
unassignObjects,
|
||||||
|
} from "../utils/revit-assignment-model";
|
||||||
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
|
const DRAG_TYPE = "application/x-revit-plan-objects";
|
||||||
|
const emptyPlan: AssignmentPlanState = { newCircuits: [], assignments: [], newRowChoices: [] };
|
||||||
|
|
||||||
|
interface RevitAssignmentDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
tree: CircuitTreeResponseDto;
|
||||||
|
externalObjects: ExternalProjectObjectsDto;
|
||||||
|
initialCircuitId: string | null;
|
||||||
|
getExpectedRevision: () => number;
|
||||||
|
onApplied: (result: ProjectCommandResultDto) => Promise<void>;
|
||||||
|
onReload: () => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revit assignment dialog (requirements §11.2–11.7). Objects are selected on
|
||||||
|
* the left and dragged (or assigned by keyboard) onto circuits, rows or a new
|
||||||
|
* circuit on the right. Every drop only extends the local plan; "Übernehmen"
|
||||||
|
* writes the complete plan as one project command and one undo step.
|
||||||
|
*/
|
||||||
|
export function RevitAssignmentDialog({
|
||||||
|
projectId,
|
||||||
|
tree,
|
||||||
|
externalObjects,
|
||||||
|
initialCircuitId,
|
||||||
|
getExpectedRevision,
|
||||||
|
onApplied,
|
||||||
|
onReload,
|
||||||
|
onClose,
|
||||||
|
}: RevitAssignmentDialogProps) {
|
||||||
|
const objects = useMemo(
|
||||||
|
() => buildAssignablePlanObjects(externalObjects, tree.distributionBoardId),
|
||||||
|
[externalObjects, tree.distributionBoardId]
|
||||||
|
);
|
||||||
|
const circuits = useMemo(() => buildPlanCircuits(tree, externalObjects), [tree, externalObjects]);
|
||||||
|
const sections = useMemo(() => buildPlanSections(tree), [tree]);
|
||||||
|
const objectById = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
|
||||||
|
const circuitById = useMemo(() => new Map(circuits.map((circuit) => [circuit.id, circuit])), [circuits]);
|
||||||
|
const limit = externalObjects.maxSocketsPerCircuit;
|
||||||
|
const withoutBoardCount = countObjectsWithoutBoard(externalObjects);
|
||||||
|
const deviceTypes = useMemo(() => {
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
for (const object of objects) {
|
||||||
|
if (!names.has(object.deviceTypeId)) {
|
||||||
|
names.set(object.deviceTypeId, object.planningValues.displayName || object.familyAndType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...names.entries()].sort((left, right) => left[1].localeCompare(right[1], "de"));
|
||||||
|
}, [objects]);
|
||||||
|
|
||||||
|
const [plan, setPlan] = useState<AssignmentPlanState>(emptyPlan);
|
||||||
|
const planRef = useRef(plan);
|
||||||
|
planRef.current = plan;
|
||||||
|
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||||
|
const [anchorId, setAnchorId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [deviceTypeFilter, setDeviceTypeFilter] = useState("");
|
||||||
|
const [socketsOnly, setSocketsOnly] = useState(false);
|
||||||
|
const [collapsedRooms, setCollapsedRooms] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedCircuits, setExpandedCircuits] = useState<Set<string>>(
|
||||||
|
() => new Set(initialCircuitId ? [initialCircuitId] : [])
|
||||||
|
);
|
||||||
|
const [dragIds, setDragIds] = useState<string[]>([]);
|
||||||
|
const [dropKey, setDropKey] = useState<string | null>(null);
|
||||||
|
const [assignTarget, setAssignTarget] = useState(initialCircuitId ? `circuit:${initialCircuitId}` : "");
|
||||||
|
const [splitOpen, setSplitOpen] = useState(false);
|
||||||
|
const [splitSectionId, setSplitSectionId] = useState("");
|
||||||
|
const [splitLimit, setSplitLimit] = useState(limit === null ? "" : String(limit));
|
||||||
|
const [confirmConflicts, setConfirmConflicts] = useState(false);
|
||||||
|
const [confirmOverLimit, setConfirmOverLimit] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const initialCircuitRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// A reload (for example after a revision conflict) keeps every planned
|
||||||
|
// assignment that is still valid and names the removed ones.
|
||||||
|
useEffect(() => {
|
||||||
|
const result = revalidatePlan(planRef.current, objects, circuits, sections);
|
||||||
|
if (result.removedCount > 0) {
|
||||||
|
setPlan(result.plan);
|
||||||
|
setNotice(
|
||||||
|
`${result.removedCount} geplante Zuordnung(en) wurden entfernt, weil sich der Projektstand geändert hat.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [objects, circuits, sections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialCircuitRef.current?.scrollIntoView({ block: "center" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolved = useMemo(
|
||||||
|
() => resolveAssignmentPlan({ objects, circuits, sections, plan, maxSocketsPerCircuit: limit }),
|
||||||
|
[objects, circuits, sections, plan, limit]
|
||||||
|
);
|
||||||
|
const resolvedByRef = useMemo(
|
||||||
|
() => new Map(resolved.circuits.map((circuit) => [circuit.refKey, circuit])),
|
||||||
|
[resolved]
|
||||||
|
);
|
||||||
|
const plannedIds = useMemo(() => new Set(plan.assignments.map((entry) => entry.objectId)), [plan]);
|
||||||
|
const targetByObjectId = useMemo(
|
||||||
|
() => new Map(plan.assignments.map((entry) => [entry.objectId, entry.target])),
|
||||||
|
[plan]
|
||||||
|
);
|
||||||
|
const rooms = useMemo(
|
||||||
|
() => buildRoomTree(objects, plannedIds, { search, deviceTypeId: deviceTypeFilter, socketsOnly }),
|
||||||
|
[objects, plannedIds, search, deviceTypeFilter, socketsOnly]
|
||||||
|
);
|
||||||
|
const orderedIds = useMemo(() => flattenRoomTree(rooms), [rooms]);
|
||||||
|
const visibleIds = useMemo(() => new Set(orderedIds), [orderedIds]);
|
||||||
|
const selectedIds = useMemo(
|
||||||
|
() => [...selection].filter((id) => visibleIds.has(id)),
|
||||||
|
[selection, visibleIds]
|
||||||
|
);
|
||||||
|
const selectedObjects = selectedIds.map((id) => objectById.get(id)!);
|
||||||
|
const newCircuitIdentifiers = useMemo(() => previewNewCircuitIdentifiers(tree, plan), [tree, plan]);
|
||||||
|
const dragObjects = dragIds.flatMap((id) => objectById.get(id) ?? []);
|
||||||
|
const conflicts = resolved.circuits.flatMap((circuit) => circuit.rows.flatMap((row) => row.conflicts));
|
||||||
|
const overLimitCircuits = resolved.circuits.filter((circuit) => circuit.overLimit);
|
||||||
|
const uniqueIssues = [...new Set(resolved.issues)];
|
||||||
|
useEffect(() => setConfirmConflicts(false), [conflicts.length]);
|
||||||
|
useEffect(() => setConfirmOverLimit(false), [overLimitCircuits.length]);
|
||||||
|
|
||||||
|
function updatePlan(next: AssignmentPlanState) {
|
||||||
|
setPlan(pruneNewCircuits(next));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assign(objectIds: readonly string[], target: AssignmentTarget) {
|
||||||
|
if (objectIds.length === 0) return;
|
||||||
|
updatePlan(assignObjects(planRef.current, objectIds, target));
|
||||||
|
setSelection((current) => new Set([...current].filter((id) => !objectIds.includes(id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignToNewCircuit(objectIds: readonly string[], sectionId: string) {
|
||||||
|
const key = createClientId();
|
||||||
|
updatePlan(assignObjects(
|
||||||
|
{ ...planRef.current, newCircuits: [...planRef.current.newCircuits, { key, sectionId }] },
|
||||||
|
objectIds,
|
||||||
|
{ kind: "new-circuit", key }
|
||||||
|
));
|
||||||
|
setSelection((current) => new Set([...current].filter((id) => !objectIds.includes(id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(objectIds: readonly string[], event: React.MouseEvent | React.ChangeEvent) {
|
||||||
|
const shift = "shiftKey" in event && event.shiftKey;
|
||||||
|
if (shift && objectIds.length === 1) {
|
||||||
|
const range = selectRange(orderedIds, anchorId, objectIds[0]!);
|
||||||
|
setSelection((current) => new Set([...current, ...range]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allSelected = objectIds.every((id) => selection.has(id));
|
||||||
|
setSelection((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
for (const id of objectIds) {
|
||||||
|
if (allSelected) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (objectIds.length === 1) setAnchorId(objectIds[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(event: DragEvent, itemIds: readonly string[]) {
|
||||||
|
const ids = itemIds.every((id) => selection.has(id)) ? selectedIds : [...itemIds];
|
||||||
|
setDragIds(ids);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids));
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
setDragIds([]);
|
||||||
|
setDropKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryReason(category: string | null | undefined, candidates: readonly AssignmentPlanObject[]) {
|
||||||
|
if (!category) return "Die Stromkreisgruppe besitzt keine Kategorie.";
|
||||||
|
const categories = categoriesOf(candidates);
|
||||||
|
if (categories.size !== 1 || !categories.has(category as never)) {
|
||||||
|
return "Kategorie der Revit-Objekte passt nicht zu dieser Stromkreisgruppe.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropProps(
|
||||||
|
key: string,
|
||||||
|
reason: string | null,
|
||||||
|
onDrop: (ids: string[]) => void
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
onDragOver: (event: DragEvent) => {
|
||||||
|
if (dragIds.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = reason ? "none" : "move";
|
||||||
|
if (dropKey !== key) setDropKey(key);
|
||||||
|
},
|
||||||
|
onDragLeave: () => setDropKey((current) => (current === key ? null : current)),
|
||||||
|
onDrop: (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const ids = dragIds;
|
||||||
|
endDrag();
|
||||||
|
if (!reason && ids.length > 0) onDrop(ids);
|
||||||
|
},
|
||||||
|
title: dragIds.length > 0 && reason ? reason : undefined,
|
||||||
|
"data-drop-state": dragIds.length === 0 ? undefined : reason ? "invalid" : dropKey === key ? "over" : "valid",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function occupancyLabel(after: number) {
|
||||||
|
return limit === null ? `${formatQuantity(after)} Steckdosen` : `${formatQuantity(after)} / ${limit} Steckdosen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function occupancy(refKey: string, before: number) {
|
||||||
|
const planned = resolvedByRef.get(refKey);
|
||||||
|
const after = planned?.socketQuantityAfter ?? before;
|
||||||
|
const predicted = dropKey !== null && dropKey.startsWith(`${refKey}|`) ? after + socketQuantity(dragObjects) : after;
|
||||||
|
const over = limit !== null && predicted > limit && predicted > before;
|
||||||
|
return (
|
||||||
|
<span className={`revit-assign-occupancy ${over ? "is-over" : ""}`}>
|
||||||
|
{occupancyLabel(predicted)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAutoSplit() {
|
||||||
|
const maximum = Number(splitLimit);
|
||||||
|
if (!Number.isInteger(maximum) || maximum < 1) {
|
||||||
|
setError("Bitte einen Höchstwert als positive ganze Zahl angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!splitSectionId) {
|
||||||
|
setError("Bitte eine Stromkreisgruppe für die neuen Stromkreise wählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = splitSocketsByRoom(selectedObjects, maximum);
|
||||||
|
let next = planRef.current;
|
||||||
|
for (const objectIds of result.circuits) {
|
||||||
|
const key = createClientId();
|
||||||
|
next = assignObjects(
|
||||||
|
{ ...next, newCircuits: [...next.newCircuits, { key, sectionId: splitSectionId }] },
|
||||||
|
objectIds,
|
||||||
|
{ kind: "new-circuit", key }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updatePlan(next);
|
||||||
|
const planned = new Set(result.circuits.flat());
|
||||||
|
setSelection((current) => new Set([...current].filter((id) => !planned.has(id))));
|
||||||
|
setSplitOpen(false);
|
||||||
|
const hints = [
|
||||||
|
`${result.circuits.length} neue Stromkreise geplant.`,
|
||||||
|
result.skippedObjectIds.length > 0
|
||||||
|
? `${result.skippedObjectIds.length} Objekt(e) sind keine Steckdosen und bleiben ausgewählt.`
|
||||||
|
: "",
|
||||||
|
result.oversizedObjectIds.length > 0
|
||||||
|
? `${result.oversizedObjectIds.length} Objekt(e) überschreiten den Höchstwert allein und erhalten je einen eigenen Stromkreis.`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean);
|
||||||
|
setNotice(hints.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignFromSelect() {
|
||||||
|
if (!assignTarget || selectedIds.length === 0) return;
|
||||||
|
const [kind, id] = [assignTarget.slice(0, assignTarget.indexOf(":")), assignTarget.slice(assignTarget.indexOf(":") + 1)];
|
||||||
|
const candidates = selectedObjects;
|
||||||
|
if (kind === "circuit") {
|
||||||
|
const circuit = circuitById.get(id);
|
||||||
|
const reason = categoryReason(circuit?.category, candidates);
|
||||||
|
if (reason) return setError(reason);
|
||||||
|
assign(selectedIds, { kind: "circuit", circuitId: id });
|
||||||
|
} else if (kind === "planned") {
|
||||||
|
const entry = planRef.current.newCircuits.find((candidate) => candidate.key === id);
|
||||||
|
const section = sections.find((candidate) => candidate.id === entry?.sectionId);
|
||||||
|
const reason = categoryReason(section?.category, candidates);
|
||||||
|
if (reason) return setError(reason);
|
||||||
|
assign(selectedIds, { kind: "new-circuit", key: id });
|
||||||
|
} else {
|
||||||
|
const section = sections.find((candidate) => candidate.id === id);
|
||||||
|
const reason = categoryReason(section?.category, candidates);
|
||||||
|
if (reason) return setError(reason);
|
||||||
|
assignToNewCircuit(selectedIds, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRowChoice(row: ResolvedRowAssignment) {
|
||||||
|
const groupKeys = [...new Set(row.objectIds.map((id) => objectGroupKey(objectById.get(id)!)))];
|
||||||
|
const keys = groupKeys.map((groupKey) => newRowChoiceKey(row.circuit, groupKey));
|
||||||
|
const current = new Set(planRef.current.newRowChoices);
|
||||||
|
const forceNew = row.row.kind === "existing";
|
||||||
|
for (const key of keys) {
|
||||||
|
if (forceNew) current.add(key);
|
||||||
|
else current.delete(key);
|
||||||
|
}
|
||||||
|
updatePlan({ ...planRef.current, newRowChoices: [...current] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitDisabled) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const rows = toAssignmentPlanRequestRows(resolved);
|
||||||
|
const usedKeys = new Set(rows.flatMap((row) => (row.kind === "new-row-in-new-circuit" ? [row.newCircuitKey] : [])));
|
||||||
|
const result = await applyExternalObjectAssignmentPlan(projectId, {
|
||||||
|
expectedRevision: getExpectedRevision(),
|
||||||
|
circuitListId: tree.circuitListId,
|
||||||
|
newCircuits: plan.newCircuits.filter((entry) => usedKeys.has(entry.key)),
|
||||||
|
rows,
|
||||||
|
confirmedConflictObjectIds: confirmConflicts ? resolved.conflictObjectIds : [],
|
||||||
|
confirmedOverLimitCircuitKeys: confirmOverLimit ? resolved.overLimitRefKeys : [],
|
||||||
|
});
|
||||||
|
await onApplied(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError && err.status === 409) {
|
||||||
|
setNotice("Der Projektstand wurde zwischenzeitlich geändert und neu geladen. Bitte den Plan prüfen und erneut übernehmen.");
|
||||||
|
await onReload();
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : "Die Zuordnung konnte nicht übernommen werden.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (plan.assignments.length > 0 && !window.confirm("Geplante Zuordnungen verwerfen?")) return;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDisabled =
|
||||||
|
isSaving ||
|
||||||
|
resolved.objectCount === 0 ||
|
||||||
|
uniqueIssues.length > 0 ||
|
||||||
|
(conflicts.length > 0 && !confirmConflicts) ||
|
||||||
|
(overLimitCircuits.length > 0 && !confirmOverLimit);
|
||||||
|
|
||||||
|
const rowLabel = (rowId: string) => {
|
||||||
|
for (const circuit of circuits) {
|
||||||
|
const row = circuit.rows.find((entry) => entry.id === rowId);
|
||||||
|
if (row) return `${circuit.equipmentIdentifier} · ${row.displayName}`;
|
||||||
|
}
|
||||||
|
return "Gerätezeile";
|
||||||
|
};
|
||||||
|
const circuitLabel = (plannedCircuit: ResolvedCircuitPlan) =>
|
||||||
|
plannedCircuit.circuit.kind === "existing"
|
||||||
|
? circuitById.get(plannedCircuit.circuit.circuitId)?.equipmentIdentifier ?? "Stromkreis"
|
||||||
|
: `${newCircuitIdentifiers.get(plannedCircuit.circuit.key) ?? "neu"} (neu)`;
|
||||||
|
|
||||||
|
function renderPlannedRows(refKey: string) {
|
||||||
|
const plannedCircuit = resolvedByRef.get(refKey);
|
||||||
|
if (!plannedCircuit) return null;
|
||||||
|
return (
|
||||||
|
<ul className="revit-assign-planned">
|
||||||
|
{plannedCircuit.rows.map((row) => {
|
||||||
|
const first = objectById.get(row.objectIds[0]!);
|
||||||
|
const fromCircuitDrop = row.objectIds.some((id) => targetByObjectId.get(id)?.kind !== "row");
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="revit-assign-planned-row"
|
||||||
|
draggable
|
||||||
|
key={row.key}
|
||||||
|
onDragEnd={endDrag}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setDragIds(row.objectIds);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(row.objectIds));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{row.row.kind === "existing" ? `→ ${rowLabel(row.row.rowId)}` : `Neue Zeile: ${first?.planningValues.displayName || first?.familyAndType || "Revit"}`}
|
||||||
|
<small className="text-secondary ms-1">
|
||||||
|
{first ? [first.roomNumber, first.roomName].filter(Boolean).join(" ") : ""} · {row.objectIds.length} Objekt(e) · Menge {formatQuantity(row.quantity)}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span className="revit-assign-planned-actions">
|
||||||
|
{row.row.kind === "new" && row.matchingRowId ? (
|
||||||
|
<button className="btn btn-link btn-sm p-0" onClick={() => toggleRowChoice(row)} type="button">
|
||||||
|
In „{rowLabel(row.matchingRowId)}“ übernehmen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{row.row.kind === "existing" && fromCircuitDrop ? (
|
||||||
|
<button className="btn btn-link btn-sm p-0" onClick={() => toggleRowChoice(row)} type="button">
|
||||||
|
Als neue Zeile
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
aria-label="Zuordnung zurücknehmen"
|
||||||
|
className="btn btn-link btn-sm p-0 text-danger"
|
||||||
|
onClick={() => updatePlan(unassignObjects(planRef.current, row.objectIds))}
|
||||||
|
title="Zuordnung zurücknehmen"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Undo2 size={14} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCircuit(section: CircuitTreeSectionDto, circuit: AssignmentPlanCircuit) {
|
||||||
|
const expanded = expandedCircuits.has(circuit.id);
|
||||||
|
const reason = categoryReason(section.category, dragObjects);
|
||||||
|
const treeCircuit = section.circuits.find((entry) => entry.id === circuit.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`revit-assign-circuit ${circuit.id === initialCircuitId ? "is-initial" : ""}`}
|
||||||
|
key={circuit.id}
|
||||||
|
ref={circuit.id === initialCircuitId ? initialCircuitRef : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="revit-assign-target"
|
||||||
|
{...dropProps(`${circuit.id}|circuit`, reason, (ids) => assign(ids, { kind: "circuit", circuitId: circuit.id }))}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? "Zeilen ausblenden" : "Zeilen anzeigen"}
|
||||||
|
className="btn btn-link btn-sm p-0"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedCircuits((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(circuit.id)) next.delete(circuit.id);
|
||||||
|
else next.add(circuit.id);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
<strong>{circuit.equipmentIdentifier}</strong>
|
||||||
|
<span className="revit-assign-name">{circuit.displayName ?? treeCircuit?.deviceRows[0]?.displayName ?? "Reserve"}</span>
|
||||||
|
{occupancy(circuit.id, circuit.linkedSocketQuantity)}
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<ul className="revit-assign-rows">
|
||||||
|
{circuit.rows.map((row) => {
|
||||||
|
const rowReason = reason ?? (dragObjects.length > 0 ? findRowIssues(row, circuit.category, dragObjects)[0] ?? null : null);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="revit-assign-target revit-assign-row"
|
||||||
|
key={row.id}
|
||||||
|
{...dropProps(`${circuit.id}|row:${row.id}`, rowReason, (ids) => assign(ids, { kind: "row", rowId: row.id }))}
|
||||||
|
>
|
||||||
|
<span>{row.displayName}</span>
|
||||||
|
<small className="text-secondary">
|
||||||
|
{row.linkedObjectCount > 0 ? `Revit ${row.linkedObjectCount}` : "manuell"}
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{circuit.rows.length === 0 ? <li className="text-secondary small">Keine Gerätezeilen</li> : null}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
{renderPlannedRows(circuit.id)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
description={`${tree.distributionBoardNumber ? `${tree.distributionBoardNumber} · ` : ""}${tree.distributionBoardName} · ${
|
||||||
|
limit === null ? "kein Steckdosen-Höchstwert" : `Höchstwert ${limit} Steckdosen je Stromkreis`
|
||||||
|
}`}
|
||||||
|
dialogSize="viewport"
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={requestClose}
|
||||||
|
onSubmit={(event) => void handleSubmit(event)}
|
||||||
|
submitDisabled={submitDisabled}
|
||||||
|
submitLabel={`Übernehmen (${resolved.objectCount} Objekte${resolved.newCircuitCount > 0 ? `, ${resolved.newCircuitCount} neue Stromkreise` : ""})`}
|
||||||
|
title="Revit-Objekte zuordnen"
|
||||||
|
>
|
||||||
|
<div className="revit-assign">
|
||||||
|
<section
|
||||||
|
aria-label="Zuordenbare Revit-Objekte"
|
||||||
|
className="revit-assign-pane"
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (dragIds.some((id) => plannedIds.has(id))) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
const ids = dragIds.filter((id) => plannedIds.has(id));
|
||||||
|
endDrag();
|
||||||
|
if (ids.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
updatePlan(unassignObjects(planRef.current, ids));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="revit-assign-filters">
|
||||||
|
<input
|
||||||
|
aria-label="Revit-Objekte suchen"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||||
|
placeholder="Suche: Raum, Familie, Auswahlkenner, Worksharing-ID"
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label="Geräteart filtern"
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
onChange={(event) => setDeviceTypeFilter(event.target.value)}
|
||||||
|
value={deviceTypeFilter}
|
||||||
|
>
|
||||||
|
<option value="">Alle Gerätearten</option>
|
||||||
|
{deviceTypes.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||||
|
</select>
|
||||||
|
<label className="form-check form-check-inline mb-0 small">
|
||||||
|
<input
|
||||||
|
checked={socketsOnly}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={(event) => setSocketsOnly(event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">nur Steckdosen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{withoutBoardCount > 0 ? (
|
||||||
|
<p className="small text-secondary mb-2">
|
||||||
|
{withoutBoardCount} Objekte ohne Verteiler – auf der Revit-Seite zuordnen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="revit-assign-scroll">
|
||||||
|
{rooms.map((room) => {
|
||||||
|
const collapsed = collapsedRooms.has(room.key);
|
||||||
|
const roomSelected = room.objectIds.filter((id) => selection.has(id)).length;
|
||||||
|
return (
|
||||||
|
<div className="revit-assign-room" key={room.key}>
|
||||||
|
<div className="revit-assign-room-header" draggable onDragEnd={endDrag} onDragStart={(event) => startDrag(event, room.objectIds)}>
|
||||||
|
<button
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label={collapsed ? `${room.label} aufklappen` : `${room.label} zuklappen`}
|
||||||
|
className="btn btn-link btn-sm p-0"
|
||||||
|
onClick={() =>
|
||||||
|
setCollapsedRooms((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(room.key)) next.delete(room.key);
|
||||||
|
else next.add(room.key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
<TriStateCheckbox
|
||||||
|
checked={roomSelected === room.objectIds.length}
|
||||||
|
indeterminate={roomSelected > 0 && roomSelected < room.objectIds.length}
|
||||||
|
label={`Raum ${room.label} auswählen`}
|
||||||
|
onChange={(event) => toggleSelection(room.objectIds, event)}
|
||||||
|
/>
|
||||||
|
<strong>{room.label}</strong>
|
||||||
|
<small className="text-secondary ms-auto">
|
||||||
|
{room.objectIds.length} Objekte{room.socketQuantity > 0 ? ` · ${formatQuantity(room.socketQuantity)} Steckdosen` : ""}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{collapsed ? null : room.groups.map((group) => {
|
||||||
|
const ids = group.objects.map((object) => object.id);
|
||||||
|
const groupSelected = ids.filter((id) => selection.has(id)).length;
|
||||||
|
return (
|
||||||
|
<div className="revit-assign-group" key={group.key}>
|
||||||
|
<div className="revit-assign-group-header" draggable onDragEnd={endDrag} onDragStart={(event) => startDrag(event, ids)}>
|
||||||
|
<TriStateCheckbox
|
||||||
|
checked={groupSelected === ids.length}
|
||||||
|
indeterminate={groupSelected > 0 && groupSelected < ids.length}
|
||||||
|
label={`Gruppe ${group.label} auswählen`}
|
||||||
|
onChange={(event) => toggleSelection(ids, event)}
|
||||||
|
/>
|
||||||
|
<span>{group.label}</span>
|
||||||
|
<small className="text-secondary ms-auto">
|
||||||
|
{ids.length} Objekte · Menge {formatQuantity(group.quantity)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{group.objects.map((object) => (
|
||||||
|
<label
|
||||||
|
className={`revit-assign-object ${selection.has(object.id) ? "is-selected" : ""}`}
|
||||||
|
draggable
|
||||||
|
key={object.id}
|
||||||
|
onDragEnd={endDrag}
|
||||||
|
onDragStart={(event) => startDrag(event, [object.id])}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked={selection.has(object.id)}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={() => undefined}
|
||||||
|
onClick={(event) => toggleSelection([object.id], event)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="revit-assign-family">{object.familyAndType}</span>
|
||||||
|
<span className="badge text-bg-light">{formatQuantity(object.planningValues.effectiveQuantity)}×</span>
|
||||||
|
<code className="small" title={object.worksharingId}>{shortWorksharingId(object.worksharingId)}</code>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rooms.length === 0 ? (
|
||||||
|
<p className="notice muted">
|
||||||
|
{objects.length === 0
|
||||||
|
? "Keine zuordenbaren Revit-Objekte für diesen Verteiler."
|
||||||
|
: plannedIds.size === objects.length
|
||||||
|
? "Alle Objekte sind verplant."
|
||||||
|
: "Keine Objekte für diesen Filter."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="revit-assign-footer">
|
||||||
|
<span className="small">
|
||||||
|
{selectedIds.length} Objekte · {formatQuantity(socketQuantity(selectedObjects))} Steckdosen ·{" "}
|
||||||
|
{new Set(selectedObjects.map((object) => object.roomId ?? object.roomNumber)).size} Räume
|
||||||
|
</span>
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
aria-label="Ziel für die Auswahl"
|
||||||
|
className="form-select form-select-sm w-auto"
|
||||||
|
onChange={(event) => setAssignTarget(event.target.value)}
|
||||||
|
value={assignTarget}
|
||||||
|
>
|
||||||
|
<option value="">Ziel wählen …</option>
|
||||||
|
{tree.sections.map((section) => (
|
||||||
|
<optgroup key={section.id} label={`${section.displayName} (${section.prefix})`}>
|
||||||
|
{section.circuits.map((circuit) => (
|
||||||
|
<option key={circuit.id} value={`circuit:${circuit.id}`}>
|
||||||
|
{circuit.equipmentIdentifier} {circuit.displayName ?? ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{plan.newCircuits.filter((entry) => entry.sectionId === section.id).map((entry) => (
|
||||||
|
<option key={entry.key} value={`planned:${entry.key}`}>
|
||||||
|
{newCircuitIdentifiers.get(entry.key)} (neu)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={`new:${section.id}`}>Neuer Stromkreis in {section.displayName}</option>
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
disabled={selectedIds.length === 0 || !assignTarget}
|
||||||
|
onClick={assignFromSelect}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Zuweisen an …
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary btn-with-icon"
|
||||||
|
disabled={selectedIds.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setSplitOpen((current) => !current);
|
||||||
|
const category = [...categoriesOf(selectedObjects)][0];
|
||||||
|
const section = tree.sections.find((entry) => entry.category === category);
|
||||||
|
if (!splitSectionId && section) setSplitSectionId(section.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Split size={14} />Automatisch aufteilen …
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{splitOpen ? (
|
||||||
|
<div className="revit-assign-split">
|
||||||
|
<select
|
||||||
|
aria-label="Stromkreisgruppe für neue Stromkreise"
|
||||||
|
className="form-select form-select-sm w-auto"
|
||||||
|
onChange={(event) => setSplitSectionId(event.target.value)}
|
||||||
|
value={splitSectionId}
|
||||||
|
>
|
||||||
|
<option value="">Stromkreisgruppe wählen …</option>
|
||||||
|
{tree.sections
|
||||||
|
.filter((section) => categoryReason(section.category, selectedObjects.filter((object) => object.isSocket)) === null)
|
||||||
|
.map((section) => (
|
||||||
|
<option key={section.id} value={section.id}>{section.displayName} ({section.prefix})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="small d-flex align-items-center gap-1">
|
||||||
|
max.
|
||||||
|
<input
|
||||||
|
aria-label="Höchstwert Steckdosen je Stromkreis"
|
||||||
|
className="form-control form-control-sm revit-assign-limit"
|
||||||
|
min={1}
|
||||||
|
onChange={(event) => setSplitLimit(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||||
|
type="number"
|
||||||
|
value={splitLimit}
|
||||||
|
/>
|
||||||
|
Steckdosen je Stromkreis
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={runAutoSplit} type="button">
|
||||||
|
Aufteilen
|
||||||
|
</button>
|
||||||
|
<small className="text-secondary w-100">
|
||||||
|
Raumweise: ganze Räume bleiben zusammen, solange sie passen; Objekte werden nie geteilt. Das Ergebnis ist nur ein Plan.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Stromkreise des Verteilers" className="revit-assign-pane">
|
||||||
|
<div className="revit-assign-scroll">
|
||||||
|
{tree.sections.map((section) => {
|
||||||
|
const sectionCircuits = circuits.filter((circuit) => circuit.sectionId === section.id);
|
||||||
|
const plannedNew = plan.newCircuits.filter((entry) => entry.sectionId === section.id);
|
||||||
|
const reason = categoryReason(section.category, dragObjects);
|
||||||
|
return (
|
||||||
|
<div className="revit-assign-section" key={section.id}>
|
||||||
|
<h3 className="h6 mb-1">
|
||||||
|
{section.displayName} <small className="text-secondary">{section.prefix}</small>
|
||||||
|
</h3>
|
||||||
|
{sectionCircuits.map((circuit) => renderCircuit(section, circuit))}
|
||||||
|
{plannedNew.map((entry) => {
|
||||||
|
const refKey = circuitRefKey({ kind: "new", key: entry.key });
|
||||||
|
return (
|
||||||
|
<div className="revit-assign-circuit is-new" key={entry.key}>
|
||||||
|
<div
|
||||||
|
className="revit-assign-target"
|
||||||
|
{...dropProps(`${refKey}|circuit`, reason, (ids) => assign(ids, { kind: "new-circuit", key: entry.key }))}
|
||||||
|
>
|
||||||
|
<strong>{newCircuitIdentifiers.get(entry.key)}</strong>
|
||||||
|
<span className="revit-assign-name">neu</span>
|
||||||
|
{occupancy(refKey, 0)}
|
||||||
|
<button
|
||||||
|
aria-label="Geplanten Stromkreis entfernen"
|
||||||
|
className="btn btn-link btn-sm p-0 text-danger"
|
||||||
|
onClick={() => {
|
||||||
|
const ids = planRef.current.assignments
|
||||||
|
.filter((assignment) => assignment.target.kind === "new-circuit" && assignment.target.key === entry.key)
|
||||||
|
.map((assignment) => assignment.objectId);
|
||||||
|
updatePlan(unassignObjects(planRef.current, ids));
|
||||||
|
}}
|
||||||
|
title="Geplanten Stromkreis entfernen"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renderPlannedRows(refKey)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
className="revit-assign-target revit-assign-new"
|
||||||
|
{...dropProps(`new-section:${section.id}|circuit`, reason, (ids) => assignToNewCircuit(ids, section.id))}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Neuer Stromkreis
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="revit-assign-review">
|
||||||
|
{notice ? <div className="alert alert-info py-2 mb-2">{notice}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger py-2 mb-2">{error}</div> : null}
|
||||||
|
{uniqueIssues.length > 0 ? (
|
||||||
|
<div className="alert alert-danger py-2 mb-2">
|
||||||
|
{uniqueIssues.map((issue) => <div key={issue}>{issue}</div>)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{conflicts.length > 0 ? (
|
||||||
|
<div className="alert alert-warning py-2 mb-2">
|
||||||
|
<strong>Abweichende Planungswerte</strong>
|
||||||
|
<table className="table table-sm mb-1 mt-1">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Objekt</th><th>Zeile</th><th>Feld</th><th>Zeilenwert</th><th>Objektwert</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resolved.circuits.flatMap((circuit) =>
|
||||||
|
circuit.rows.flatMap((row) =>
|
||||||
|
row.conflicts.map((conflict) => (
|
||||||
|
<tr key={`${conflict.objectId}-${conflict.field}`}>
|
||||||
|
<td><code className="small">{shortWorksharingId(objectById.get(conflict.objectId)?.worksharingId ?? "")}</code></td>
|
||||||
|
<td>{row.row.kind === "existing" ? rowLabel(row.row.rowId) : circuitLabel(circuit)}</td>
|
||||||
|
<td>{assignmentConflictFieldLabels[conflict.field]}</td>
|
||||||
|
<td>{formatConflictValue(conflict.rowValue)}</td>
|
||||||
|
<td>{formatConflictValue(conflict.objectValue)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<label className="form-check mb-0">
|
||||||
|
<input checked={confirmConflicts} className="form-check-input" onChange={(event) => setConfirmConflicts(event.target.checked)} type="checkbox" />
|
||||||
|
<span className="form-check-label">Abweichungen bestätigen – die Werte der Gerätezeile gelten für die zugeordneten Objekte.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{overLimitCircuits.length > 0 ? (
|
||||||
|
<div className="alert alert-warning py-2 mb-2">
|
||||||
|
<strong>Höchstwert überschritten</strong>
|
||||||
|
<ul className="mb-1">
|
||||||
|
{overLimitCircuits.map((circuit) => (
|
||||||
|
<li key={circuit.refKey}>
|
||||||
|
{circuitLabel(circuit)}: {formatQuantity(circuit.socketQuantityAfter)} von {limit} Steckdosen
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<label className="form-check mb-0">
|
||||||
|
<input checked={confirmOverLimit} className="form-check-input" onChange={(event) => setConfirmOverLimit(event.target.checked)} type="checkbox" />
|
||||||
|
<span className="form-check-label">Überschreitung ausdrücklich bestätigen.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriStateCheckbox({
|
||||||
|
checked,
|
||||||
|
indeterminate,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
indeterminate: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
aria-label={label}
|
||||||
|
checked={checked}
|
||||||
|
className="form-check-input mt-0"
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortWorksharingId(value: string) {
|
||||||
|
return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConflictValue(value: string | number | null) {
|
||||||
|
if (value === null) return "–";
|
||||||
|
return typeof value === "number" ? value.toLocaleString("de-DE", { maximumFractionDigits: 3 }) : value;
|
||||||
|
}
|
||||||
104
src/frontend/components/revit-linked-objects-dialog.tsx
Normal file
104
src/frontend/components/revit-linked-objects-dialog.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import type { ExternalProjectObjectDto } from "../types";
|
||||||
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revit objects linked to one device row (requirements §11.8). Single
|
||||||
|
* objects or all objects can be detached explicitly; detached objects take
|
||||||
|
* the current row values as planning values and overrides.
|
||||||
|
*/
|
||||||
|
export function RevitLinkedObjectsDialog({
|
||||||
|
rowLabel,
|
||||||
|
objects,
|
||||||
|
isSaving,
|
||||||
|
onDetach,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
rowLabel: string;
|
||||||
|
objects: readonly ExternalProjectObjectDto[];
|
||||||
|
isSaving: boolean;
|
||||||
|
onDetach: (objectIds: string[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const allSelected = objects.length > 0 && selected.size === objects.length;
|
||||||
|
|
||||||
|
function toggle(objectId: string) {
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(objectId)) next.delete(objectId);
|
||||||
|
else next.add(objectId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
description={rowLabel}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void onDetach([...selected]);
|
||||||
|
}}
|
||||||
|
submitDisabled={selected.size === 0}
|
||||||
|
submitLabel={selected.size === 0 ? "Auswahl lösen" : `${selected.size} Objekt(e) lösen`}
|
||||||
|
title="Verknüpfte Revit-Objekte"
|
||||||
|
>
|
||||||
|
<p className="small text-secondary">
|
||||||
|
Gelöste Objekte werden wieder unzugeordnet und übernehmen die aktuellen
|
||||||
|
Werte der Gerätezeile als Planungswerte.
|
||||||
|
</p>
|
||||||
|
<table className="table table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input
|
||||||
|
aria-label="Alle Objekte auswählen"
|
||||||
|
checked={allSelected}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={() =>
|
||||||
|
setSelected(allSelected ? new Set() : new Set(objects.map((object) => object.id)))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>Worksharing-ID</th>
|
||||||
|
<th>Familie und Typ</th>
|
||||||
|
<th>Raum</th>
|
||||||
|
<th className="text-end">Menge</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{objects.map((object) => (
|
||||||
|
<tr key={object.id}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
aria-label={`${object.worksharingId} auswählen`}
|
||||||
|
checked={selected.has(object.id)}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={() => toggle(object.id)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td><code className="small">{object.worksharingId}</code></td>
|
||||||
|
<td>{object.familyAndType}</td>
|
||||||
|
<td>{[object.sourceRoomNumber, object.sourceRoomName].filter(Boolean).join(" ") || "–"}</td>
|
||||||
|
<td className="text-end">{object.planningValues.effectiveQuantity}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
disabled={isSaving || objects.length === 0}
|
||||||
|
onClick={() => void onDetach(objects.map((object) => object.id))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Alle {objects.length} lösen
|
||||||
|
</button>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
||||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||||
|
import type { AssignmentPlanRequestRow } from "../../shared/revit/assignment-plan";
|
||||||
import {
|
import {
|
||||||
createCircuitUpdateProjectCommand,
|
createCircuitUpdateProjectCommand,
|
||||||
type CircuitUpdatePatch,
|
type CircuitUpdatePatch,
|
||||||
|
|
@ -309,6 +310,23 @@ export function assignExternalProjectObjectsToNewCircuit(projectId: string, expe
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyExternalObjectAssignmentPlan(
|
||||||
|
projectId: string,
|
||||||
|
input: {
|
||||||
|
expectedRevision: number;
|
||||||
|
circuitListId: string;
|
||||||
|
newCircuits: Array<{ key: string; sectionId: string }>;
|
||||||
|
rows: AssignmentPlanRequestRow[];
|
||||||
|
confirmedConflictObjectIds: string[];
|
||||||
|
confirmedOverLimitCircuitKeys: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<ProjectCommandResultDto & { newCircuitIds: string[] }>(
|
||||||
|
`/api/projects/${projectId}/external-model/objects/assignment-plan`,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProjectHistory(projectId: string) {
|
export function getProjectHistory(projectId: string) {
|
||||||
return request<ProjectHistoryStateDto>(
|
return request<ProjectHistoryStateDto>(
|
||||||
`/api/projects/${projectId}/history`
|
`/api/projects/${projectId}/history`
|
||||||
|
|
|
||||||
340
src/frontend/utils/revit-assignment-model.ts
Normal file
340
src/frontend/utils/revit-assignment-model.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
import { nextCircuitIdentifiers } from "../../domain/services/circuit-numbering.service";
|
||||||
|
import type {
|
||||||
|
AssignmentPlanCategory,
|
||||||
|
AssignmentPlanCircuit,
|
||||||
|
AssignmentPlanObject,
|
||||||
|
AssignmentPlanSection,
|
||||||
|
AssignmentPlanState,
|
||||||
|
AssignmentTarget,
|
||||||
|
} from "../../shared/revit/assignment-plan";
|
||||||
|
import { objectGroupKey } from "../../shared/revit/assignment-plan";
|
||||||
|
import type {
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
ExternalProjectObjectDto,
|
||||||
|
ExternalProjectObjectsDto,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapters between editor DTOs and the shared assignment planning module,
|
||||||
|
* plus the pure view model of the Revit assignment dialog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const planCategories = new Set<string>(["lighting", "single_phase", "three_phase"]);
|
||||||
|
|
||||||
|
export function isAssignableObject(object: ExternalProjectObjectDto, distributionBoardId: string) {
|
||||||
|
return (
|
||||||
|
object.presenceStatus === "present" &&
|
||||||
|
object.circuitDeviceRowId === null &&
|
||||||
|
object.distributionBoardId === distributionBoardId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countObjectsWithoutBoard(objects: ExternalProjectObjectsDto | null) {
|
||||||
|
return (objects?.objects ?? []).filter(
|
||||||
|
(object) =>
|
||||||
|
object.presenceStatus === "present" &&
|
||||||
|
object.circuitDeviceRowId === null &&
|
||||||
|
object.distributionBoardId === null
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPlanObject(
|
||||||
|
object: ExternalProjectObjectDto,
|
||||||
|
socketDeviceTypeIds: ReadonlySet<string>
|
||||||
|
): AssignmentPlanObject {
|
||||||
|
const category = object.planningValues.category;
|
||||||
|
return {
|
||||||
|
id: object.id,
|
||||||
|
worksharingId: object.worksharingId,
|
||||||
|
familyAndType: object.familyAndType,
|
||||||
|
selectionMarker: object.selectionMarker,
|
||||||
|
deviceTypeId: object.deviceTypeId,
|
||||||
|
roomId: object.roomId,
|
||||||
|
roomNumber: object.sourceRoomNumber,
|
||||||
|
roomName: object.sourceRoomName,
|
||||||
|
linkedProjectDeviceId: object.linkedProjectDeviceId,
|
||||||
|
isSocket: socketDeviceTypeIds.has(object.deviceTypeId),
|
||||||
|
planningValues: {
|
||||||
|
...object.planningValues,
|
||||||
|
category: category && planCategories.has(category) ? category : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssignablePlanObjects(
|
||||||
|
objects: ExternalProjectObjectsDto,
|
||||||
|
distributionBoardId: string
|
||||||
|
): AssignmentPlanObject[] {
|
||||||
|
const socketIds = new Set(objects.socketDeviceTypeIds);
|
||||||
|
return objects.objects
|
||||||
|
.filter((object) => isAssignableObject(object, distributionBoardId))
|
||||||
|
.map((object) => toPlanObject(object, socketIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPlanSections(tree: CircuitTreeResponseDto): AssignmentPlanSection[] {
|
||||||
|
return tree.sections.map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
category: (section.category ?? null) as AssignmentPlanCategory | null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPlanCircuits(
|
||||||
|
tree: CircuitTreeResponseDto,
|
||||||
|
objects: ExternalProjectObjectsDto
|
||||||
|
): AssignmentPlanCircuit[] {
|
||||||
|
const socketIds = new Set(objects.socketDeviceTypeIds);
|
||||||
|
const linkedByRowId = new Map<string, ExternalProjectObjectDto[]>();
|
||||||
|
for (const object of objects.objects) {
|
||||||
|
if (object.circuitDeviceRowId === null) continue;
|
||||||
|
linkedByRowId.set(object.circuitDeviceRowId, [
|
||||||
|
...(linkedByRowId.get(object.circuitDeviceRowId) ?? []),
|
||||||
|
object,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return tree.sections.flatMap((section) =>
|
||||||
|
section.circuits.map((circuit) => {
|
||||||
|
const linked = circuit.deviceRows.flatMap((row) => linkedByRowId.get(row.id) ?? []);
|
||||||
|
return {
|
||||||
|
id: circuit.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
category: (section.category ?? null) as AssignmentPlanCategory | null,
|
||||||
|
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||||
|
displayName: circuit.displayName ?? null,
|
||||||
|
linkedSocketQuantity: linked
|
||||||
|
.filter((object) => socketIds.has(object.deviceTypeId))
|
||||||
|
.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
|
||||||
|
rows: circuit.deviceRows.map((row) => {
|
||||||
|
const rowObjects = linkedByRowId.get(row.id) ?? [];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
circuitId: circuit.id,
|
||||||
|
roomId: row.roomId ?? null,
|
||||||
|
displayName: row.displayName,
|
||||||
|
linkedProjectDeviceId: row.linkedProjectDeviceId ?? null,
|
||||||
|
category: row.category ?? null,
|
||||||
|
connectionKind: row.connectionKind ?? null,
|
||||||
|
powerPerUnit: row.powerPerUnit,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi ?? null,
|
||||||
|
costGroup: row.costGroup ?? null,
|
||||||
|
remark: row.remark ?? null,
|
||||||
|
linkedSelectionMarkers: [
|
||||||
|
...new Set(rowObjects.map((object) => object.selectionMarker.trim())),
|
||||||
|
],
|
||||||
|
linkedObjectCount: rowObjects.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevitObjectFilter {
|
||||||
|
search: string;
|
||||||
|
deviceTypeId: string;
|
||||||
|
socketsOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevitObjectGroupView {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
familyAndTypes: string[];
|
||||||
|
objects: AssignmentPlanObject[];
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevitRoomView {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
groups: RevitObjectGroupView[];
|
||||||
|
objectIds: string[];
|
||||||
|
quantity: number;
|
||||||
|
socketQuantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Left pane: unplanned objects as room → group → object, natural order. */
|
||||||
|
export function buildRoomTree(
|
||||||
|
objects: readonly AssignmentPlanObject[],
|
||||||
|
plannedObjectIds: ReadonlySet<string>,
|
||||||
|
filter: RevitObjectFilter
|
||||||
|
): RevitRoomView[] {
|
||||||
|
const needle = filter.search.trim().toLocaleLowerCase("de-DE");
|
||||||
|
const visible = objects.filter((object) => {
|
||||||
|
if (plannedObjectIds.has(object.id)) return false;
|
||||||
|
if (filter.socketsOnly && !object.isSocket) return false;
|
||||||
|
if (filter.deviceTypeId && object.deviceTypeId !== filter.deviceTypeId) return false;
|
||||||
|
return (
|
||||||
|
!needle ||
|
||||||
|
[
|
||||||
|
object.roomNumber,
|
||||||
|
object.roomName,
|
||||||
|
object.familyAndType,
|
||||||
|
object.selectionMarker,
|
||||||
|
object.worksharingId,
|
||||||
|
object.planningValues.displayName ?? "",
|
||||||
|
].some((value) => value.toLocaleLowerCase("de-DE").includes(needle))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const rooms = new Map<string, AssignmentPlanObject[]>();
|
||||||
|
for (const object of visible) {
|
||||||
|
const key = object.roomId ?? (object.roomNumber || object.roomName ? `source:${object.roomNumber}|${object.roomName}` : "");
|
||||||
|
rooms.set(key, [...(rooms.get(key) ?? []), object]);
|
||||||
|
}
|
||||||
|
return [...rooms.entries()]
|
||||||
|
.map(([key, roomObjects]): RevitRoomView => {
|
||||||
|
const first = roomObjects[0]!;
|
||||||
|
const groups = new Map<string, AssignmentPlanObject[]>();
|
||||||
|
for (const object of roomObjects) {
|
||||||
|
const groupKey = objectGroupKey(object);
|
||||||
|
groups.set(groupKey, [...(groups.get(groupKey) ?? []), object]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: key || "without-room",
|
||||||
|
label: key === "" ? "Ohne Raum" : [first.roomNumber, first.roomName].filter(Boolean).join(" ") || "Raum",
|
||||||
|
groups: [...groups.entries()]
|
||||||
|
.map(([groupKey, groupObjects]) => {
|
||||||
|
const sample = groupObjects[0]!;
|
||||||
|
return {
|
||||||
|
key: groupKey,
|
||||||
|
label:
|
||||||
|
sample.planningValues.displayName ||
|
||||||
|
sample.selectionMarker ||
|
||||||
|
sample.familyAndType,
|
||||||
|
familyAndTypes: [...new Set(groupObjects.map((object) => object.familyAndType))],
|
||||||
|
objects: [...groupObjects].sort((left, right) =>
|
||||||
|
compareNatural(left.familyAndType, right.familyAndType) ||
|
||||||
|
compareNatural(left.worksharingId, right.worksharingId)
|
||||||
|
),
|
||||||
|
quantity: sumQuantity(groupObjects),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => compareNatural(left.label, right.label)),
|
||||||
|
objectIds: roomObjects.map((object) => object.id),
|
||||||
|
quantity: sumQuantity(roomObjects),
|
||||||
|
socketQuantity: sumQuantity(roomObjects.filter((object) => object.isSocket)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.key === "without-room" || right.key === "without-room") {
|
||||||
|
return left.key === right.key ? 0 : left.key === "without-room" ? 1 : -1;
|
||||||
|
}
|
||||||
|
return compareNatural(left.label, right.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids in visual order, used for Shift+click range selection. */
|
||||||
|
export function flattenRoomTree(rooms: readonly RevitRoomView[]): string[] {
|
||||||
|
return rooms.flatMap((room) => room.groups.flatMap((group) => group.objects.map((object) => object.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectRange(
|
||||||
|
orderedIds: readonly string[],
|
||||||
|
anchorId: string | null,
|
||||||
|
targetId: string
|
||||||
|
): string[] {
|
||||||
|
const anchor = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
|
||||||
|
const target = orderedIds.indexOf(targetId);
|
||||||
|
if (anchor < 0 || target < 0) return [targetId];
|
||||||
|
const [from, to] = anchor < target ? [anchor, target] : [target, anchor];
|
||||||
|
return orderedIds.slice(from, to + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignObjects(
|
||||||
|
plan: AssignmentPlanState,
|
||||||
|
objectIds: readonly string[],
|
||||||
|
target: AssignmentTarget
|
||||||
|
): AssignmentPlanState {
|
||||||
|
const ids = new Set(objectIds);
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
assignments: [
|
||||||
|
...plan.assignments.filter((assignment) => !ids.has(assignment.objectId)),
|
||||||
|
...objectIds.map((objectId) => ({ objectId, target })),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unassignObjects(
|
||||||
|
plan: AssignmentPlanState,
|
||||||
|
objectIds: readonly string[]
|
||||||
|
): AssignmentPlanState {
|
||||||
|
const ids = new Set(objectIds);
|
||||||
|
return pruneNewCircuits({
|
||||||
|
...plan,
|
||||||
|
assignments: plan.assignments.filter((assignment) => !ids.has(assignment.objectId)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops planned new circuits that no longer receive any object. */
|
||||||
|
export function pruneNewCircuits(plan: AssignmentPlanState): AssignmentPlanState {
|
||||||
|
const used = new Set(
|
||||||
|
plan.assignments.flatMap((assignment) =>
|
||||||
|
assignment.target.kind === "new-circuit" ? [assignment.target.key] : []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { ...plan, newCircuits: plan.newCircuits.filter((entry) => used.has(entry.key)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps only assignments whose object is still assignable and whose target
|
||||||
|
* still exists, for example after a reload caused by a revision conflict.
|
||||||
|
*/
|
||||||
|
export function revalidatePlan(
|
||||||
|
plan: AssignmentPlanState,
|
||||||
|
objects: readonly AssignmentPlanObject[],
|
||||||
|
circuits: readonly AssignmentPlanCircuit[],
|
||||||
|
sections: readonly AssignmentPlanSection[]
|
||||||
|
): { plan: AssignmentPlanState; removedCount: number } {
|
||||||
|
const objectIds = new Set(objects.map((object) => object.id));
|
||||||
|
const circuitIds = new Set(circuits.map((circuit) => circuit.id));
|
||||||
|
const rowIds = new Set(circuits.flatMap((circuit) => circuit.rows.map((row) => row.id)));
|
||||||
|
const sectionIds = new Set(sections.map((section) => section.id));
|
||||||
|
const newCircuits = plan.newCircuits.filter((entry) => sectionIds.has(entry.sectionId));
|
||||||
|
const newKeys = new Set(newCircuits.map((entry) => entry.key));
|
||||||
|
const assignments = plan.assignments.filter((assignment) => {
|
||||||
|
if (!objectIds.has(assignment.objectId)) return false;
|
||||||
|
const target = assignment.target;
|
||||||
|
if (target.kind === "row") return rowIds.has(target.rowId);
|
||||||
|
if (target.kind === "circuit") return circuitIds.has(target.circuitId);
|
||||||
|
return newKeys.has(target.key);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
plan: pruneNewCircuits({ ...plan, newCircuits, assignments }),
|
||||||
|
removedCount: plan.assignments.length - assignments.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BMK preview for planned new circuits; the server plans them identically. */
|
||||||
|
export function previewNewCircuitIdentifiers(
|
||||||
|
tree: CircuitTreeResponseDto,
|
||||||
|
plan: AssignmentPlanState
|
||||||
|
): Map<string, string> {
|
||||||
|
const preview = new Map<string, string>();
|
||||||
|
for (const section of tree.sections) {
|
||||||
|
const keys = plan.newCircuits.filter((entry) => entry.sectionId === section.id).map((entry) => entry.key);
|
||||||
|
if (keys.length === 0) continue;
|
||||||
|
const identifiers = nextCircuitIdentifiers(
|
||||||
|
section.prefix,
|
||||||
|
section.circuits.map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
keys.length
|
||||||
|
);
|
||||||
|
keys.forEach((key, index) => preview.set(key, identifiers[index]!));
|
||||||
|
}
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoriesOf(objects: readonly AssignmentPlanObject[]) {
|
||||||
|
return new Set(objects.map((object) => object.planningValues.category));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuantity(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toLocaleString("de-DE", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumQuantity(objects: readonly AssignmentPlanObject[]) {
|
||||||
|
return objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNatural(left: string, right: string) {
|
||||||
|
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
|
||||||
|
}
|
||||||
|
|
@ -289,6 +289,7 @@ describe("project settings presentation", () => {
|
||||||
workStage: null,
|
workStage: null,
|
||||||
projectLead: null,
|
projectLead: null,
|
||||||
projectEditor: null,
|
projectEditor: null,
|
||||||
|
maxSocketsPerCircuit: null,
|
||||||
currentRevision: 0,
|
currentRevision: 0,
|
||||||
},
|
},
|
||||||
usedDistributionBoardSupplyTypes: ["SiBe"],
|
usedDistributionBoardSupplyTypes: ["SiBe"],
|
||||||
|
|
|
||||||
157
tests/revit-assignment-model.test.ts
Normal file
157
tests/revit-assignment-model.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import type {
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
ExternalProjectObjectDto,
|
||||||
|
ExternalProjectObjectsDto,
|
||||||
|
} from "../src/frontend/types.js";
|
||||||
|
import {
|
||||||
|
assignObjects,
|
||||||
|
buildAssignablePlanObjects,
|
||||||
|
buildPlanCircuits,
|
||||||
|
buildPlanSections,
|
||||||
|
buildRoomTree,
|
||||||
|
countObjectsWithoutBoard,
|
||||||
|
flattenRoomTree,
|
||||||
|
previewNewCircuitIdentifiers,
|
||||||
|
revalidatePlan,
|
||||||
|
selectRange,
|
||||||
|
unassignObjects,
|
||||||
|
} from "../src/frontend/utils/revit-assignment-model.js";
|
||||||
|
import { applyExternalObjectAssignmentPlan } from "../src/frontend/utils/api.js";
|
||||||
|
|
||||||
|
function object(id: string, overrides: Partial<ExternalProjectObjectDto> = {}): ExternalProjectObjectDto {
|
||||||
|
return {
|
||||||
|
id, worksharingId: `ws-${id}`, ifcGuid: null, deviceTypeId: "type-socket",
|
||||||
|
familyAndType: "Steckdose: Doppel", selectionMarker: "SD", sourceCircuitIdentifier: "",
|
||||||
|
sourceRoomNumber: "1.01", sourceRoomName: "Büro", roomId: "room-1",
|
||||||
|
distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId: null,
|
||||||
|
presenceStatus: "present",
|
||||||
|
planningValues: {
|
||||||
|
displayName: "Steckdose", category: "single_phase", connectionKind: null, effectiveQuantity: 2,
|
||||||
|
powerPerUnitW: 200, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectsDto(objects: ExternalProjectObjectDto[]): ExternalProjectObjectsDto {
|
||||||
|
return {
|
||||||
|
currentRevision: 3, sourceName: "Revit", maxSocketsPerCircuit: 8, socketDeviceTypeIds: ["type-socket"],
|
||||||
|
distributionBoards: [], projectDevices: [], objects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tree(): CircuitTreeResponseDto {
|
||||||
|
return {
|
||||||
|
circuitListId: "list-1", distributionBoardId: "board-1", distributionBoardName: "UV 1",
|
||||||
|
currentRevision: 3, singlePhaseVoltageV: 230, threePhaseVoltageV: 400,
|
||||||
|
distributionBoardSimultaneityFactor: 1, distributionBoardTotalPower: 0,
|
||||||
|
distributionBoardTotalPowerWithSimultaneityFactor: 0, headerComponents: [], footerComponents: [],
|
||||||
|
sections: [{
|
||||||
|
id: "section-2", key: "single", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 20,
|
||||||
|
category: "single_phase", groupNumber: 1, sectionTotalPower: 0, components: [],
|
||||||
|
circuits: [{
|
||||||
|
id: "circuit-1", circuitListId: "list-1", sectionId: "section-2", equipmentIdentifier: "-2F1.7",
|
||||||
|
displayName: "Steckdose", sortOrder: 10, isReserve: false, circuitTotalPower: 0,
|
||||||
|
circuitApparentPower: 0, circuitCosPhi: 1, downstreamBoardLoads: [],
|
||||||
|
deviceRows: [{
|
||||||
|
id: "row-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", category: "single_phase",
|
||||||
|
roomId: "room-1", quantity: 2, manualQuantity: 0, powerPerUnit: 0.2, simultaneityFactor: 1,
|
||||||
|
rowTotalPower: 0.4, externalObjectCount: 1,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Revit assignment dialog model", () => {
|
||||||
|
it("offers only present, unassigned objects of the board and counts objects without board", () => {
|
||||||
|
const dto = objectsDto([
|
||||||
|
object("free"),
|
||||||
|
object("linked", { circuitDeviceRowId: "row-1" }),
|
||||||
|
object("missing", { presenceStatus: "missing" }),
|
||||||
|
object("other-board", { distributionBoardId: "board-2" }),
|
||||||
|
object("no-board", { distributionBoardId: null }),
|
||||||
|
object("light", { deviceTypeId: "type-light" }),
|
||||||
|
]);
|
||||||
|
const planObjects = buildAssignablePlanObjects(dto, "board-1");
|
||||||
|
assert.deepEqual(planObjects.map((entry) => [entry.id, entry.isSocket]), [["free", true], ["light", false]]);
|
||||||
|
assert.equal(countObjectsWithoutBoard(dto), 1);
|
||||||
|
|
||||||
|
const circuits = buildPlanCircuits(tree(), dto);
|
||||||
|
assert.equal(circuits[0]!.linkedSocketQuantity, 2);
|
||||||
|
assert.deepEqual(circuits[0]!.rows[0]!.linkedSelectionMarkers, ["SD"]);
|
||||||
|
assert.deepEqual(buildPlanSections(tree()), [{ id: "section-2", category: "single_phase" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a room tree without planned objects and selects ranges in visual order", () => {
|
||||||
|
const dto = objectsDto([
|
||||||
|
object("b", { roomId: "room-2", sourceRoomNumber: "1.10" }),
|
||||||
|
object("a2"),
|
||||||
|
object("a1"),
|
||||||
|
object("x", { roomId: null, sourceRoomNumber: "", sourceRoomName: "" }),
|
||||||
|
]);
|
||||||
|
const planObjects = buildAssignablePlanObjects(dto, "board-1");
|
||||||
|
const rooms = buildRoomTree(planObjects, new Set(["a2"]), { search: "", deviceTypeId: "", socketsOnly: false });
|
||||||
|
assert.deepEqual(rooms.map((room) => room.label), ["1.01 Büro", "1.10 Büro", "Ohne Raum"]);
|
||||||
|
const ordered = flattenRoomTree(rooms);
|
||||||
|
assert.deepEqual(ordered, ["a1", "b", "x"]);
|
||||||
|
assert.deepEqual(selectRange(ordered, "a1", "x"), ["a1", "b", "x"]);
|
||||||
|
assert.deepEqual(selectRange(ordered, null, "b"), ["b"]);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildRoomTree(planObjects, new Set(), { search: "1.10", deviceTypeId: "", socketsOnly: false })
|
||||||
|
.flatMap((room) => room.objectIds),
|
||||||
|
["b"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps valid assignments after a reload and previews BMKs without filling gaps", () => {
|
||||||
|
const dto = objectsDto([object("a1"), object("a2")]);
|
||||||
|
const planObjects = buildAssignablePlanObjects(dto, "board-1");
|
||||||
|
let plan = assignObjects(
|
||||||
|
{ newCircuits: [{ key: "n1", sectionId: "section-2" }, { key: "n2", sectionId: "section-2" }], assignments: [], newRowChoices: [] },
|
||||||
|
["a1"],
|
||||||
|
{ kind: "new-circuit", key: "n1" }
|
||||||
|
);
|
||||||
|
plan = assignObjects(plan, ["a2"], { kind: "new-circuit", key: "n2" });
|
||||||
|
assert.deepEqual([...previewNewCircuitIdentifiers(tree(), plan).values()], ["-2F1.8", "-2F1.9"]);
|
||||||
|
|
||||||
|
const reloaded = revalidatePlan(plan, planObjects.filter((entry) => entry.id !== "a2"), buildPlanCircuits(tree(), dto), buildPlanSections(tree()));
|
||||||
|
assert.equal(reloaded.removedCount, 1);
|
||||||
|
assert.deepEqual(reloaded.plan.newCircuits.map((entry) => entry.key), ["n1"]);
|
||||||
|
assert.deepEqual(unassignObjects(plan, ["a1", "a2"]).newCircuits, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts the plan to the assignment-plan route", async () => {
|
||||||
|
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({ url: String(input), method: init?.method ?? "GET", body: JSON.parse(String(init?.body)) });
|
||||||
|
return new Response(JSON.stringify({ history: { currentRevision: 4 }, newCircuitIds: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
expectedRevision: 3, circuitListId: "list-1", newCircuits: [],
|
||||||
|
rows: [{ kind: "existing-row" as const, rowId: "row-1", objectIds: ["a1"] }],
|
||||||
|
confirmedConflictObjectIds: [], confirmedOverLimitCircuitKeys: [],
|
||||||
|
};
|
||||||
|
await applyExternalObjectAssignmentPlan("project-1", body);
|
||||||
|
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects/assignment-plan", method: "POST", body }]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the drawer with the assignment dialog in the editor", () => {
|
||||||
|
const editor = readFileSync("src/frontend/components/circuit-tree-editor.tsx", "utf8");
|
||||||
|
const dialog = readFileSync("src/frontend/components/revit-assignment-dialog.tsx", "utf8");
|
||||||
|
assert.doesNotMatch(editor, /revit-object-drawer|x-revit-object-ids|assignExternalProjectObjectsTo/);
|
||||||
|
assert.match(editor, /Revit-Objekte zuordnen/);
|
||||||
|
assert.match(editor, /RevitLinkedObjectsDialog/);
|
||||||
|
assert.match(dialog, /Automatisch aufteilen/);
|
||||||
|
assert.match(dialog, /Überschreitung ausdrücklich bestätigen/);
|
||||||
|
assert.match(dialog, /Abweichungen bestätigen/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue