diff --git a/AGENTS.md b/AGENTS.md index 6a45eb3..91b1d33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,7 +323,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported 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 diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index c88e4aa..6d3606e 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -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:`. - `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 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b4a87fb..e658622 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -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 diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 7d7feb8..9e6c13f 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -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 diff --git a/src/app/globals.css b/src/app/globals.css index 411b1c3..6050b5c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); + } +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index b66d193..e85bf8d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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(null); const [projectDevices, setProjectDevices] = useState([]); const [externalObjects, setExternalObjects] = useState(null); - const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false); - const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState([]); + // 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(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(); - 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 ? ( + { + applyProjectCommandResult(result); + setRevitAssignment(null); + await loadTree({ showLoading: false }); + }} + onClose={() => setRevitAssignment(null)} + onReload={() => loadTree({ showLoading: false })} + projectId={projectId} + tree={data} + /> + ) : null} + {linkedObjectsRowId && externalObjects ? ( + object.circuitDeviceRowId === linkedObjectsRowId + )} + onClose={() => setLinkedObjectsRowId(null)} + onDetach={handleDetachExternalObjects} + rowLabel={describeDeviceRow(linkedObjectsRowId)} + /> + ) : null} {cableSizingEditorCircuit ? ( - sum + group.objects.length, 0)})`} - onClick={() => setIsRevitDrawerOpen((current) => !current)} - showLabel - /> + {externalObjects && externalObjects.objects.length > 0 ? ( + setRevitAssignment({ initialCircuitId: null })} + showLabel + /> + ) : null} ) : null}
- {isRevitDrawerOpen ? ( - - ) : null} {isProjectDeviceDrawerOpen ? (