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
project data atomically and records a new `restore` revision with a complete
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;
versions containing legacy CSV configuration or external CSV state are rejected
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
protection and its external object links. Its history-only inverse requires the
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.
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
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
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
(without them) and baseline version 1 stay executable and keep the current
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
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

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
preview hash and source decisions and updates linked row quantities atomically.
- `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`
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
objects marked missing and adjusts linked row quantities in the same command.
- `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
[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
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. |
| 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. |
| R11 | geplant | 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. |
| R13 | geplant | 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. |
| R11 | abgeschlossen | Projekteinstellung `maxSocketsPerCircuit` und Geräteart-Merkmal `isSocket` samt Hochstufung und Einstellungs-UI. |
| R12 | abgeschlossen | Reines Planungsmodul für Row-Bildung, Belegung, Konflikte und raumweise Aufteilung. |
| R13 | abgeschlossen | Atomarer Command `external-object.apply-assignment-plan` mit Route und exakter Inverse. |
| R14 | abgeschlossen | Zuordnungsdialog mit Drag-and-drop und Plan, Row-Objektliste mit Einzel-Lösen, Entfernen des R7-Drawers. |
## R0 – Audit

View file

@ -3120,3 +3120,219 @@ button.structure-entry-main:focus-visible {
justify-content: center;
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,
FolderPlus,
Funnel,
Link2,
ListOrdered,
ListPlus,
ListX,
@ -126,9 +127,6 @@ import {
deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand,
assignExternalProjectObjectsToRow,
assignExternalProjectObjectsToNewRow,
assignExternalProjectObjectsToNewCircuit,
detachExternalProjectObjectsFromRow,
getExternalProjectObjects,
getCircuitTree,
@ -192,6 +190,9 @@ import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { GridContextMenu, type GridContextMenuEntry } from "./grid-context-menu";
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 {
circuitGroupCategoryLabels,
type CircuitGroupCategory,
@ -365,8 +366,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [externalObjects, setExternalObjects] = useState<ExternalProjectObjectsDto | null>(null);
const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false);
const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState<string[]>([]);
// Revit assignment dialog (requirements §11.2); null while closed.
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] =
useState(false);
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
@ -418,26 +423,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
[orderedColumns, visibleColumnKeys]
);
const externalObjectGroups = useMemo(() => {
if (!data || !externalObjects) return [];
const groups = new Map<string, typeof externalObjects.objects>();
for (const object of externalObjects.objects) {
if (object.presenceStatus !== "present" || object.circuitDeviceRowId !== null || object.distributionBoardId !== data.distributionBoardId) continue;
const key = JSON.stringify([
object.roomId, object.selectionMarker, object.deviceTypeId,
object.planningValues.category, object.linkedProjectDeviceId,
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]);
const assignableExternalObjectCount = useMemo(
() =>
data && externalObjects
? externalObjects.objects.filter((object) =>
isAssignableObject(object, data.distributionBoardId)
).length
: 0,
[data, externalObjects]
);
async function loadTree(options?: { showLoading?: boolean }) {
const showLoading = options?.showLoading ?? false;
@ -2549,6 +2543,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
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))) {
entries.push({ kind: "separator" });
@ -3521,51 +3533,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId);
}
async function handleAssignExternalObjects(rowId: string, objectIds: 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;
await runCommand({
label: "Revit-Objekte zuordnen",
redo: async () => {
const result = await assignExternalProjectObjectsToRow(
projectId,
getExpectedProjectRevision(),
objectIds,
rowId,
objectIds
);
applyProjectCommandResult(result);
setDraggingExternalObjectIds([]);
return null;
},
});
function describeDeviceRow(rowId: string) {
for (const section of data?.sections ?? []) {
for (const circuit of section.circuits) {
const deviceRow = circuit.deviceRows.find((entry) => entry.id === rowId);
if (deviceRow) {
return `${circuit.equipmentIdentifier} · ${deviceRow.displayName || deviceRow.name}`;
}
}
}
return "Gerätezeile";
}
async function handleAssignExternalObjectsToNewRow(circuitId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neue Gerätezeile anlegen?`)) 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;
async function handleDetachExternalObjects(objectIds: string[]) {
if (objectIds.length === 0) return;
await runCommand({
label: "Revit-Objekte lösen",
redo: async () => {
const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
applyProjectCommandResult(result);
setLinkedObjectsRowId(null);
return null;
},
});
@ -3903,6 +3890,33 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitProtection}
/>
) : 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 ? (
<CableSizingModal
circuit={cableSizingEditorCircuit}
@ -3990,15 +4004,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
showLabel
/>
<IconButton
ariaControls="revit-object-drawer"
ariaExpanded={isRevitDrawerOpen}
className="project-device-drawer-toggle"
icon={isRevitDrawerOpen ? PanelRightClose : PanelRightOpen}
label={isRevitDrawerOpen ? "Revit-Objekte schließen" : `Revit-Objekte öffnen (${externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)})`}
onClick={() => setIsRevitDrawerOpen((current) => !current)}
showLabel
/>
{externalObjects && externalObjects.objects.length > 0 ? (
<IconButton
className="project-device-drawer-toggle"
disabled={isSaving || historyBusy}
icon={Link2}
label={`Revit-Objekte zuordnen (${assignableExternalObjectCount})`}
onClick={() => setRevitAssignment({ initialCircuitId: null })}
showLabel
/>
) : null}
<IconButton
ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen}
@ -4047,62 +4062,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
/>
) : null}
<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 ? (
<aside
className="project-device-sidebar project-device-drawer"
@ -4892,11 +4851,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}`}
onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
return;
}
if (draggingCircuitCount > 0) {
const valid = isCircuitDropTargetValid(
row.sectionId
@ -5037,21 +4991,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
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 (row.rowType === "placeholder") {
void handleCircuitReorderDrop(event, {
@ -5451,7 +5390,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
className="badge rounded-pill text-bg-info border-0"
title="Verknüpfte Revit-Objekte anzeigen und lösen"
onClick={() => void handleDetachExternalObjects(row.device!.id)}
onClick={() => setLinkedObjectsRowId(row.device!.id)}
>
Revit {row.device.externalObjectCount}
</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";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import type { AssignmentPlanRequestRow } from "../../shared/revit/assignment-plan";
import {
createCircuitUpdateProjectCommand,
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) {
return request<ProjectHistoryStateDto>(
`/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,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
currentRevision: 0,
},
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/);
});
});