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:
Julian Appel 2026-10-30 21:39:27 +01:00
parent fd45a1caeb
commit 191455c745
12 changed files with 1885 additions and 157 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
}

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

View file

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

View 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" });
}

View file

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

View 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/);
});
});