forked from jappel/leistungsbilanz-ts
Replace the Revit drawer with the assignment dialog
- RevitAssignmentDialog: objects as room/group/object with multi-select, filters and drag-and-drop or keyboard assignment onto rows, circuits or new circuits; room-wise automatic split under the socket limit; local plan with review of conflicts and limit overruns, one command on apply and re-check after a revision conflict. - Row badge and context menu list linked objects and detach single objects or all; the context menu opens the dialog for a circuit. - Remove the R7 drawer and direct grid drops; update AGENTS.md, API and architecture docs and mark R11-R14 complete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd45a1caeb
commit
191455c745
12 changed files with 1885 additions and 157 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -323,7 +323,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
|||
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
{externalObjects && externalObjects.objects.length > 0 ? (
|
||||
<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)}
|
||||
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>
|
||||
|
|
|
|||
896
src/frontend/components/revit-assignment-dialog.tsx
Normal file
896
src/frontend/components/revit-assignment-dialog.tsx
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Plus, Split, Undo2, X } from "lucide-react";
|
||||
import React, { type DragEvent, type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
assignmentConflictFieldLabels,
|
||||
circuitRefKey,
|
||||
findRowIssues,
|
||||
newRowChoiceKey,
|
||||
objectGroupKey,
|
||||
resolveAssignmentPlan,
|
||||
socketQuantity,
|
||||
splitSocketsByRoom,
|
||||
toAssignmentPlanRequestRows,
|
||||
type AssignmentPlanCircuit,
|
||||
type AssignmentPlanObject,
|
||||
type AssignmentPlanState,
|
||||
type AssignmentTarget,
|
||||
type ResolvedCircuitPlan,
|
||||
type ResolvedRowAssignment,
|
||||
} from "../../shared/revit/assignment-plan";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
CircuitTreeSectionDto,
|
||||
ExternalProjectObjectsDto,
|
||||
ProjectCommandResultDto,
|
||||
} from "../types";
|
||||
import { ApiRequestError, applyExternalObjectAssignmentPlan } from "../utils/api";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
import {
|
||||
assignObjects,
|
||||
buildAssignablePlanObjects,
|
||||
buildPlanCircuits,
|
||||
buildPlanSections,
|
||||
buildRoomTree,
|
||||
categoriesOf,
|
||||
countObjectsWithoutBoard,
|
||||
flattenRoomTree,
|
||||
formatQuantity,
|
||||
previewNewCircuitIdentifiers,
|
||||
pruneNewCircuits,
|
||||
revalidatePlan,
|
||||
selectRange,
|
||||
unassignObjects,
|
||||
} from "../utils/revit-assignment-model";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
const DRAG_TYPE = "application/x-revit-plan-objects";
|
||||
const emptyPlan: AssignmentPlanState = { newCircuits: [], assignments: [], newRowChoices: [] };
|
||||
|
||||
interface RevitAssignmentDialogProps {
|
||||
projectId: string;
|
||||
tree: CircuitTreeResponseDto;
|
||||
externalObjects: ExternalProjectObjectsDto;
|
||||
initialCircuitId: string | null;
|
||||
getExpectedRevision: () => number;
|
||||
onApplied: (result: ProjectCommandResultDto) => Promise<void>;
|
||||
onReload: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revit assignment dialog (requirements §11.2–11.7). Objects are selected on
|
||||
* the left and dragged (or assigned by keyboard) onto circuits, rows or a new
|
||||
* circuit on the right. Every drop only extends the local plan; "Übernehmen"
|
||||
* writes the complete plan as one project command and one undo step.
|
||||
*/
|
||||
export function RevitAssignmentDialog({
|
||||
projectId,
|
||||
tree,
|
||||
externalObjects,
|
||||
initialCircuitId,
|
||||
getExpectedRevision,
|
||||
onApplied,
|
||||
onReload,
|
||||
onClose,
|
||||
}: RevitAssignmentDialogProps) {
|
||||
const objects = useMemo(
|
||||
() => buildAssignablePlanObjects(externalObjects, tree.distributionBoardId),
|
||||
[externalObjects, tree.distributionBoardId]
|
||||
);
|
||||
const circuits = useMemo(() => buildPlanCircuits(tree, externalObjects), [tree, externalObjects]);
|
||||
const sections = useMemo(() => buildPlanSections(tree), [tree]);
|
||||
const objectById = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
|
||||
const circuitById = useMemo(() => new Map(circuits.map((circuit) => [circuit.id, circuit])), [circuits]);
|
||||
const limit = externalObjects.maxSocketsPerCircuit;
|
||||
const withoutBoardCount = countObjectsWithoutBoard(externalObjects);
|
||||
const deviceTypes = useMemo(() => {
|
||||
const names = new Map<string, string>();
|
||||
for (const object of objects) {
|
||||
if (!names.has(object.deviceTypeId)) {
|
||||
names.set(object.deviceTypeId, object.planningValues.displayName || object.familyAndType);
|
||||
}
|
||||
}
|
||||
return [...names.entries()].sort((left, right) => left[1].localeCompare(right[1], "de"));
|
||||
}, [objects]);
|
||||
|
||||
const [plan, setPlan] = useState<AssignmentPlanState>(emptyPlan);
|
||||
const planRef = useRef(plan);
|
||||
planRef.current = plan;
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
const [anchorId, setAnchorId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [deviceTypeFilter, setDeviceTypeFilter] = useState("");
|
||||
const [socketsOnly, setSocketsOnly] = useState(false);
|
||||
const [collapsedRooms, setCollapsedRooms] = useState<Set<string>>(new Set());
|
||||
const [expandedCircuits, setExpandedCircuits] = useState<Set<string>>(
|
||||
() => new Set(initialCircuitId ? [initialCircuitId] : [])
|
||||
);
|
||||
const [dragIds, setDragIds] = useState<string[]>([]);
|
||||
const [dropKey, setDropKey] = useState<string | null>(null);
|
||||
const [assignTarget, setAssignTarget] = useState(initialCircuitId ? `circuit:${initialCircuitId}` : "");
|
||||
const [splitOpen, setSplitOpen] = useState(false);
|
||||
const [splitSectionId, setSplitSectionId] = useState("");
|
||||
const [splitLimit, setSplitLimit] = useState(limit === null ? "" : String(limit));
|
||||
const [confirmConflicts, setConfirmConflicts] = useState(false);
|
||||
const [confirmOverLimit, setConfirmOverLimit] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const initialCircuitRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// A reload (for example after a revision conflict) keeps every planned
|
||||
// assignment that is still valid and names the removed ones.
|
||||
useEffect(() => {
|
||||
const result = revalidatePlan(planRef.current, objects, circuits, sections);
|
||||
if (result.removedCount > 0) {
|
||||
setPlan(result.plan);
|
||||
setNotice(
|
||||
`${result.removedCount} geplante Zuordnung(en) wurden entfernt, weil sich der Projektstand geändert hat.`
|
||||
);
|
||||
}
|
||||
}, [objects, circuits, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
initialCircuitRef.current?.scrollIntoView({ block: "center" });
|
||||
}, []);
|
||||
|
||||
const resolved = useMemo(
|
||||
() => resolveAssignmentPlan({ objects, circuits, sections, plan, maxSocketsPerCircuit: limit }),
|
||||
[objects, circuits, sections, plan, limit]
|
||||
);
|
||||
const resolvedByRef = useMemo(
|
||||
() => new Map(resolved.circuits.map((circuit) => [circuit.refKey, circuit])),
|
||||
[resolved]
|
||||
);
|
||||
const plannedIds = useMemo(() => new Set(plan.assignments.map((entry) => entry.objectId)), [plan]);
|
||||
const targetByObjectId = useMemo(
|
||||
() => new Map(plan.assignments.map((entry) => [entry.objectId, entry.target])),
|
||||
[plan]
|
||||
);
|
||||
const rooms = useMemo(
|
||||
() => buildRoomTree(objects, plannedIds, { search, deviceTypeId: deviceTypeFilter, socketsOnly }),
|
||||
[objects, plannedIds, search, deviceTypeFilter, socketsOnly]
|
||||
);
|
||||
const orderedIds = useMemo(() => flattenRoomTree(rooms), [rooms]);
|
||||
const visibleIds = useMemo(() => new Set(orderedIds), [orderedIds]);
|
||||
const selectedIds = useMemo(
|
||||
() => [...selection].filter((id) => visibleIds.has(id)),
|
||||
[selection, visibleIds]
|
||||
);
|
||||
const selectedObjects = selectedIds.map((id) => objectById.get(id)!);
|
||||
const newCircuitIdentifiers = useMemo(() => previewNewCircuitIdentifiers(tree, plan), [tree, plan]);
|
||||
const dragObjects = dragIds.flatMap((id) => objectById.get(id) ?? []);
|
||||
const conflicts = resolved.circuits.flatMap((circuit) => circuit.rows.flatMap((row) => row.conflicts));
|
||||
const overLimitCircuits = resolved.circuits.filter((circuit) => circuit.overLimit);
|
||||
const uniqueIssues = [...new Set(resolved.issues)];
|
||||
useEffect(() => setConfirmConflicts(false), [conflicts.length]);
|
||||
useEffect(() => setConfirmOverLimit(false), [overLimitCircuits.length]);
|
||||
|
||||
function updatePlan(next: AssignmentPlanState) {
|
||||
setPlan(pruneNewCircuits(next));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function assign(objectIds: readonly string[], target: AssignmentTarget) {
|
||||
if (objectIds.length === 0) return;
|
||||
updatePlan(assignObjects(planRef.current, objectIds, target));
|
||||
setSelection((current) => new Set([...current].filter((id) => !objectIds.includes(id))));
|
||||
}
|
||||
|
||||
function assignToNewCircuit(objectIds: readonly string[], sectionId: string) {
|
||||
const key = createClientId();
|
||||
updatePlan(assignObjects(
|
||||
{ ...planRef.current, newCircuits: [...planRef.current.newCircuits, { key, sectionId }] },
|
||||
objectIds,
|
||||
{ kind: "new-circuit", key }
|
||||
));
|
||||
setSelection((current) => new Set([...current].filter((id) => !objectIds.includes(id))));
|
||||
}
|
||||
|
||||
function toggleSelection(objectIds: readonly string[], event: React.MouseEvent | React.ChangeEvent) {
|
||||
const shift = "shiftKey" in event && event.shiftKey;
|
||||
if (shift && objectIds.length === 1) {
|
||||
const range = selectRange(orderedIds, anchorId, objectIds[0]!);
|
||||
setSelection((current) => new Set([...current, ...range]));
|
||||
return;
|
||||
}
|
||||
const allSelected = objectIds.every((id) => selection.has(id));
|
||||
setSelection((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of objectIds) {
|
||||
if (allSelected) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (objectIds.length === 1) setAnchorId(objectIds[0]!);
|
||||
}
|
||||
|
||||
function startDrag(event: DragEvent, itemIds: readonly string[]) {
|
||||
const ids = itemIds.every((id) => selection.has(id)) ? selectedIds : [...itemIds];
|
||||
setDragIds(ids);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids));
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
setDragIds([]);
|
||||
setDropKey(null);
|
||||
}
|
||||
|
||||
function categoryReason(category: string | null | undefined, candidates: readonly AssignmentPlanObject[]) {
|
||||
if (!category) return "Die Stromkreisgruppe besitzt keine Kategorie.";
|
||||
const categories = categoriesOf(candidates);
|
||||
if (categories.size !== 1 || !categories.has(category as never)) {
|
||||
return "Kategorie der Revit-Objekte passt nicht zu dieser Stromkreisgruppe.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dropProps(
|
||||
key: string,
|
||||
reason: string | null,
|
||||
onDrop: (ids: string[]) => void
|
||||
) {
|
||||
return {
|
||||
onDragOver: (event: DragEvent) => {
|
||||
if (dragIds.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = reason ? "none" : "move";
|
||||
if (dropKey !== key) setDropKey(key);
|
||||
},
|
||||
onDragLeave: () => setDropKey((current) => (current === key ? null : current)),
|
||||
onDrop: (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const ids = dragIds;
|
||||
endDrag();
|
||||
if (!reason && ids.length > 0) onDrop(ids);
|
||||
},
|
||||
title: dragIds.length > 0 && reason ? reason : undefined,
|
||||
"data-drop-state": dragIds.length === 0 ? undefined : reason ? "invalid" : dropKey === key ? "over" : "valid",
|
||||
};
|
||||
}
|
||||
|
||||
function occupancyLabel(after: number) {
|
||||
return limit === null ? `${formatQuantity(after)} Steckdosen` : `${formatQuantity(after)} / ${limit} Steckdosen`;
|
||||
}
|
||||
|
||||
function occupancy(refKey: string, before: number) {
|
||||
const planned = resolvedByRef.get(refKey);
|
||||
const after = planned?.socketQuantityAfter ?? before;
|
||||
const predicted = dropKey !== null && dropKey.startsWith(`${refKey}|`) ? after + socketQuantity(dragObjects) : after;
|
||||
const over = limit !== null && predicted > limit && predicted > before;
|
||||
return (
|
||||
<span className={`revit-assign-occupancy ${over ? "is-over" : ""}`}>
|
||||
{occupancyLabel(predicted)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function runAutoSplit() {
|
||||
const maximum = Number(splitLimit);
|
||||
if (!Number.isInteger(maximum) || maximum < 1) {
|
||||
setError("Bitte einen Höchstwert als positive ganze Zahl angeben.");
|
||||
return;
|
||||
}
|
||||
if (!splitSectionId) {
|
||||
setError("Bitte eine Stromkreisgruppe für die neuen Stromkreise wählen.");
|
||||
return;
|
||||
}
|
||||
const result = splitSocketsByRoom(selectedObjects, maximum);
|
||||
let next = planRef.current;
|
||||
for (const objectIds of result.circuits) {
|
||||
const key = createClientId();
|
||||
next = assignObjects(
|
||||
{ ...next, newCircuits: [...next.newCircuits, { key, sectionId: splitSectionId }] },
|
||||
objectIds,
|
||||
{ kind: "new-circuit", key }
|
||||
);
|
||||
}
|
||||
updatePlan(next);
|
||||
const planned = new Set(result.circuits.flat());
|
||||
setSelection((current) => new Set([...current].filter((id) => !planned.has(id))));
|
||||
setSplitOpen(false);
|
||||
const hints = [
|
||||
`${result.circuits.length} neue Stromkreise geplant.`,
|
||||
result.skippedObjectIds.length > 0
|
||||
? `${result.skippedObjectIds.length} Objekt(e) sind keine Steckdosen und bleiben ausgewählt.`
|
||||
: "",
|
||||
result.oversizedObjectIds.length > 0
|
||||
? `${result.oversizedObjectIds.length} Objekt(e) überschreiten den Höchstwert allein und erhalten je einen eigenen Stromkreis.`
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
setNotice(hints.join(" "));
|
||||
}
|
||||
|
||||
function assignFromSelect() {
|
||||
if (!assignTarget || selectedIds.length === 0) return;
|
||||
const [kind, id] = [assignTarget.slice(0, assignTarget.indexOf(":")), assignTarget.slice(assignTarget.indexOf(":") + 1)];
|
||||
const candidates = selectedObjects;
|
||||
if (kind === "circuit") {
|
||||
const circuit = circuitById.get(id);
|
||||
const reason = categoryReason(circuit?.category, candidates);
|
||||
if (reason) return setError(reason);
|
||||
assign(selectedIds, { kind: "circuit", circuitId: id });
|
||||
} else if (kind === "planned") {
|
||||
const entry = planRef.current.newCircuits.find((candidate) => candidate.key === id);
|
||||
const section = sections.find((candidate) => candidate.id === entry?.sectionId);
|
||||
const reason = categoryReason(section?.category, candidates);
|
||||
if (reason) return setError(reason);
|
||||
assign(selectedIds, { kind: "new-circuit", key: id });
|
||||
} else {
|
||||
const section = sections.find((candidate) => candidate.id === id);
|
||||
const reason = categoryReason(section?.category, candidates);
|
||||
if (reason) return setError(reason);
|
||||
assignToNewCircuit(selectedIds, id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRowChoice(row: ResolvedRowAssignment) {
|
||||
const groupKeys = [...new Set(row.objectIds.map((id) => objectGroupKey(objectById.get(id)!)))];
|
||||
const keys = groupKeys.map((groupKey) => newRowChoiceKey(row.circuit, groupKey));
|
||||
const current = new Set(planRef.current.newRowChoices);
|
||||
const forceNew = row.row.kind === "existing";
|
||||
for (const key of keys) {
|
||||
if (forceNew) current.add(key);
|
||||
else current.delete(key);
|
||||
}
|
||||
updatePlan({ ...planRef.current, newRowChoices: [...current] });
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitDisabled) return;
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = toAssignmentPlanRequestRows(resolved);
|
||||
const usedKeys = new Set(rows.flatMap((row) => (row.kind === "new-row-in-new-circuit" ? [row.newCircuitKey] : [])));
|
||||
const result = await applyExternalObjectAssignmentPlan(projectId, {
|
||||
expectedRevision: getExpectedRevision(),
|
||||
circuitListId: tree.circuitListId,
|
||||
newCircuits: plan.newCircuits.filter((entry) => usedKeys.has(entry.key)),
|
||||
rows,
|
||||
confirmedConflictObjectIds: confirmConflicts ? resolved.conflictObjectIds : [],
|
||||
confirmedOverLimitCircuitKeys: confirmOverLimit ? resolved.overLimitRefKeys : [],
|
||||
});
|
||||
await onApplied(result);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError && err.status === 409) {
|
||||
setNotice("Der Projektstand wurde zwischenzeitlich geändert und neu geladen. Bitte den Plan prüfen und erneut übernehmen.");
|
||||
await onReload();
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Die Zuordnung konnte nicht übernommen werden.");
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (plan.assignments.length > 0 && !window.confirm("Geplante Zuordnungen verwerfen?")) return;
|
||||
onClose();
|
||||
}
|
||||
|
||||
const submitDisabled =
|
||||
isSaving ||
|
||||
resolved.objectCount === 0 ||
|
||||
uniqueIssues.length > 0 ||
|
||||
(conflicts.length > 0 && !confirmConflicts) ||
|
||||
(overLimitCircuits.length > 0 && !confirmOverLimit);
|
||||
|
||||
const rowLabel = (rowId: string) => {
|
||||
for (const circuit of circuits) {
|
||||
const row = circuit.rows.find((entry) => entry.id === rowId);
|
||||
if (row) return `${circuit.equipmentIdentifier} · ${row.displayName}`;
|
||||
}
|
||||
return "Gerätezeile";
|
||||
};
|
||||
const circuitLabel = (plannedCircuit: ResolvedCircuitPlan) =>
|
||||
plannedCircuit.circuit.kind === "existing"
|
||||
? circuitById.get(plannedCircuit.circuit.circuitId)?.equipmentIdentifier ?? "Stromkreis"
|
||||
: `${newCircuitIdentifiers.get(plannedCircuit.circuit.key) ?? "neu"} (neu)`;
|
||||
|
||||
function renderPlannedRows(refKey: string) {
|
||||
const plannedCircuit = resolvedByRef.get(refKey);
|
||||
if (!plannedCircuit) return null;
|
||||
return (
|
||||
<ul className="revit-assign-planned">
|
||||
{plannedCircuit.rows.map((row) => {
|
||||
const first = objectById.get(row.objectIds[0]!);
|
||||
const fromCircuitDrop = row.objectIds.some((id) => targetByObjectId.get(id)?.kind !== "row");
|
||||
return (
|
||||
<li
|
||||
className="revit-assign-planned-row"
|
||||
draggable
|
||||
key={row.key}
|
||||
onDragEnd={endDrag}
|
||||
onDragStart={(event) => {
|
||||
setDragIds(row.objectIds);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(row.objectIds));
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{row.row.kind === "existing" ? `→ ${rowLabel(row.row.rowId)}` : `Neue Zeile: ${first?.planningValues.displayName || first?.familyAndType || "Revit"}`}
|
||||
<small className="text-secondary ms-1">
|
||||
{first ? [first.roomNumber, first.roomName].filter(Boolean).join(" ") : ""} · {row.objectIds.length} Objekt(e) · Menge {formatQuantity(row.quantity)}
|
||||
</small>
|
||||
</span>
|
||||
<span className="revit-assign-planned-actions">
|
||||
{row.row.kind === "new" && row.matchingRowId ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={() => toggleRowChoice(row)} type="button">
|
||||
In „{rowLabel(row.matchingRowId)}“ übernehmen
|
||||
</button>
|
||||
) : null}
|
||||
{row.row.kind === "existing" && fromCircuitDrop ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={() => toggleRowChoice(row)} type="button">
|
||||
Als neue Zeile
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="Zuordnung zurücknehmen"
|
||||
className="btn btn-link btn-sm p-0 text-danger"
|
||||
onClick={() => updatePlan(unassignObjects(planRef.current, row.objectIds))}
|
||||
title="Zuordnung zurücknehmen"
|
||||
type="button"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCircuit(section: CircuitTreeSectionDto, circuit: AssignmentPlanCircuit) {
|
||||
const expanded = expandedCircuits.has(circuit.id);
|
||||
const reason = categoryReason(section.category, dragObjects);
|
||||
const treeCircuit = section.circuits.find((entry) => entry.id === circuit.id);
|
||||
return (
|
||||
<div
|
||||
className={`revit-assign-circuit ${circuit.id === initialCircuitId ? "is-initial" : ""}`}
|
||||
key={circuit.id}
|
||||
ref={circuit.id === initialCircuitId ? initialCircuitRef : undefined}
|
||||
>
|
||||
<div
|
||||
className="revit-assign-target"
|
||||
{...dropProps(`${circuit.id}|circuit`, reason, (ids) => assign(ids, { kind: "circuit", circuitId: circuit.id }))}
|
||||
>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Zeilen ausblenden" : "Zeilen anzeigen"}
|
||||
className="btn btn-link btn-sm p-0"
|
||||
onClick={() =>
|
||||
setExpandedCircuits((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(circuit.id)) next.delete(circuit.id);
|
||||
else next.add(circuit.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<strong>{circuit.equipmentIdentifier}</strong>
|
||||
<span className="revit-assign-name">{circuit.displayName ?? treeCircuit?.deviceRows[0]?.displayName ?? "Reserve"}</span>
|
||||
{occupancy(circuit.id, circuit.linkedSocketQuantity)}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ul className="revit-assign-rows">
|
||||
{circuit.rows.map((row) => {
|
||||
const rowReason = reason ?? (dragObjects.length > 0 ? findRowIssues(row, circuit.category, dragObjects)[0] ?? null : null);
|
||||
return (
|
||||
<li
|
||||
className="revit-assign-target revit-assign-row"
|
||||
key={row.id}
|
||||
{...dropProps(`${circuit.id}|row:${row.id}`, rowReason, (ids) => assign(ids, { kind: "row", rowId: row.id }))}
|
||||
>
|
||||
<span>{row.displayName}</span>
|
||||
<small className="text-secondary">
|
||||
{row.linkedObjectCount > 0 ? `Revit ${row.linkedObjectCount}` : "manuell"}
|
||||
</small>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{circuit.rows.length === 0 ? <li className="text-secondary small">Keine Gerätezeilen</li> : null}
|
||||
</ul>
|
||||
) : null}
|
||||
{renderPlannedRows(circuit.id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={`${tree.distributionBoardNumber ? `${tree.distributionBoardNumber} · ` : ""}${tree.distributionBoardName} · ${
|
||||
limit === null ? "kein Steckdosen-Höchstwert" : `Höchstwert ${limit} Steckdosen je Stromkreis`
|
||||
}`}
|
||||
dialogSize="viewport"
|
||||
isSaving={isSaving}
|
||||
onClose={requestClose}
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
submitDisabled={submitDisabled}
|
||||
submitLabel={`Übernehmen (${resolved.objectCount} Objekte${resolved.newCircuitCount > 0 ? `, ${resolved.newCircuitCount} neue Stromkreise` : ""})`}
|
||||
title="Revit-Objekte zuordnen"
|
||||
>
|
||||
<div className="revit-assign">
|
||||
<section
|
||||
aria-label="Zuordenbare Revit-Objekte"
|
||||
className="revit-assign-pane"
|
||||
onDragOver={(event) => {
|
||||
if (dragIds.some((id) => plannedIds.has(id))) event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const ids = dragIds.filter((id) => plannedIds.has(id));
|
||||
endDrag();
|
||||
if (ids.length > 0) {
|
||||
event.preventDefault();
|
||||
updatePlan(unassignObjects(planRef.current, ids));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="revit-assign-filters">
|
||||
<input
|
||||
aria-label="Revit-Objekte suchen"
|
||||
className="form-control form-control-sm"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
placeholder="Suche: Raum, Familie, Auswahlkenner, Worksharing-ID"
|
||||
value={search}
|
||||
/>
|
||||
<select
|
||||
aria-label="Geräteart filtern"
|
||||
className="form-select form-select-sm"
|
||||
onChange={(event) => setDeviceTypeFilter(event.target.value)}
|
||||
value={deviceTypeFilter}
|
||||
>
|
||||
<option value="">Alle Gerätearten</option>
|
||||
{deviceTypes.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<label className="form-check form-check-inline mb-0 small">
|
||||
<input
|
||||
checked={socketsOnly}
|
||||
className="form-check-input"
|
||||
onChange={(event) => setSocketsOnly(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="form-check-label">nur Steckdosen</span>
|
||||
</label>
|
||||
</div>
|
||||
{withoutBoardCount > 0 ? (
|
||||
<p className="small text-secondary mb-2">
|
||||
{withoutBoardCount} Objekte ohne Verteiler – auf der Revit-Seite zuordnen.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="revit-assign-scroll">
|
||||
{rooms.map((room) => {
|
||||
const collapsed = collapsedRooms.has(room.key);
|
||||
const roomSelected = room.objectIds.filter((id) => selection.has(id)).length;
|
||||
return (
|
||||
<div className="revit-assign-room" key={room.key}>
|
||||
<div className="revit-assign-room-header" draggable onDragEnd={endDrag} onDragStart={(event) => startDrag(event, room.objectIds)}>
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? `${room.label} aufklappen` : `${room.label} zuklappen`}
|
||||
className="btn btn-link btn-sm p-0"
|
||||
onClick={() =>
|
||||
setCollapsedRooms((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(room.key)) next.delete(room.key);
|
||||
else next.add(room.key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<TriStateCheckbox
|
||||
checked={roomSelected === room.objectIds.length}
|
||||
indeterminate={roomSelected > 0 && roomSelected < room.objectIds.length}
|
||||
label={`Raum ${room.label} auswählen`}
|
||||
onChange={(event) => toggleSelection(room.objectIds, event)}
|
||||
/>
|
||||
<strong>{room.label}</strong>
|
||||
<small className="text-secondary ms-auto">
|
||||
{room.objectIds.length} Objekte{room.socketQuantity > 0 ? ` · ${formatQuantity(room.socketQuantity)} Steckdosen` : ""}
|
||||
</small>
|
||||
</div>
|
||||
{collapsed ? null : room.groups.map((group) => {
|
||||
const ids = group.objects.map((object) => object.id);
|
||||
const groupSelected = ids.filter((id) => selection.has(id)).length;
|
||||
return (
|
||||
<div className="revit-assign-group" key={group.key}>
|
||||
<div className="revit-assign-group-header" draggable onDragEnd={endDrag} onDragStart={(event) => startDrag(event, ids)}>
|
||||
<TriStateCheckbox
|
||||
checked={groupSelected === ids.length}
|
||||
indeterminate={groupSelected > 0 && groupSelected < ids.length}
|
||||
label={`Gruppe ${group.label} auswählen`}
|
||||
onChange={(event) => toggleSelection(ids, event)}
|
||||
/>
|
||||
<span>{group.label}</span>
|
||||
<small className="text-secondary ms-auto">
|
||||
{ids.length} Objekte · Menge {formatQuantity(group.quantity)}
|
||||
</small>
|
||||
</div>
|
||||
{group.objects.map((object) => (
|
||||
<label
|
||||
className={`revit-assign-object ${selection.has(object.id) ? "is-selected" : ""}`}
|
||||
draggable
|
||||
key={object.id}
|
||||
onDragEnd={endDrag}
|
||||
onDragStart={(event) => startDrag(event, [object.id])}
|
||||
>
|
||||
<input
|
||||
checked={selection.has(object.id)}
|
||||
className="form-check-input"
|
||||
onChange={() => undefined}
|
||||
onClick={(event) => toggleSelection([object.id], event)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="revit-assign-family">{object.familyAndType}</span>
|
||||
<span className="badge text-bg-light">{formatQuantity(object.planningValues.effectiveQuantity)}×</span>
|
||||
<code className="small" title={object.worksharingId}>{shortWorksharingId(object.worksharingId)}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rooms.length === 0 ? (
|
||||
<p className="notice muted">
|
||||
{objects.length === 0
|
||||
? "Keine zuordenbaren Revit-Objekte für diesen Verteiler."
|
||||
: plannedIds.size === objects.length
|
||||
? "Alle Objekte sind verplant."
|
||||
: "Keine Objekte für diesen Filter."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="revit-assign-footer">
|
||||
<span className="small">
|
||||
{selectedIds.length} Objekte · {formatQuantity(socketQuantity(selectedObjects))} Steckdosen ·{" "}
|
||||
{new Set(selectedObjects.map((object) => object.roomId ?? object.roomNumber)).size} Räume
|
||||
</span>
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<select
|
||||
aria-label="Ziel für die Auswahl"
|
||||
className="form-select form-select-sm w-auto"
|
||||
onChange={(event) => setAssignTarget(event.target.value)}
|
||||
value={assignTarget}
|
||||
>
|
||||
<option value="">Ziel wählen …</option>
|
||||
{tree.sections.map((section) => (
|
||||
<optgroup key={section.id} label={`${section.displayName} (${section.prefix})`}>
|
||||
{section.circuits.map((circuit) => (
|
||||
<option key={circuit.id} value={`circuit:${circuit.id}`}>
|
||||
{circuit.equipmentIdentifier} {circuit.displayName ?? ""}
|
||||
</option>
|
||||
))}
|
||||
{plan.newCircuits.filter((entry) => entry.sectionId === section.id).map((entry) => (
|
||||
<option key={entry.key} value={`planned:${entry.key}`}>
|
||||
{newCircuitIdentifiers.get(entry.key)} (neu)
|
||||
</option>
|
||||
))}
|
||||
<option value={`new:${section.id}`}>Neuer Stromkreis in {section.displayName}</option>
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={selectedIds.length === 0 || !assignTarget}
|
||||
onClick={assignFromSelect}
|
||||
type="button"
|
||||
>
|
||||
Zuweisen an …
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary btn-with-icon"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => {
|
||||
setSplitOpen((current) => !current);
|
||||
const category = [...categoriesOf(selectedObjects)][0];
|
||||
const section = tree.sections.find((entry) => entry.category === category);
|
||||
if (!splitSectionId && section) setSplitSectionId(section.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Split size={14} />Automatisch aufteilen …
|
||||
</button>
|
||||
</div>
|
||||
{splitOpen ? (
|
||||
<div className="revit-assign-split">
|
||||
<select
|
||||
aria-label="Stromkreisgruppe für neue Stromkreise"
|
||||
className="form-select form-select-sm w-auto"
|
||||
onChange={(event) => setSplitSectionId(event.target.value)}
|
||||
value={splitSectionId}
|
||||
>
|
||||
<option value="">Stromkreisgruppe wählen …</option>
|
||||
{tree.sections
|
||||
.filter((section) => categoryReason(section.category, selectedObjects.filter((object) => object.isSocket)) === null)
|
||||
.map((section) => (
|
||||
<option key={section.id} value={section.id}>{section.displayName} ({section.prefix})</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="small d-flex align-items-center gap-1">
|
||||
max.
|
||||
<input
|
||||
aria-label="Höchstwert Steckdosen je Stromkreis"
|
||||
className="form-control form-control-sm revit-assign-limit"
|
||||
min={1}
|
||||
onChange={(event) => setSplitLimit(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
type="number"
|
||||
value={splitLimit}
|
||||
/>
|
||||
Steckdosen je Stromkreis
|
||||
</label>
|
||||
<button className="btn btn-sm btn-primary" onClick={runAutoSplit} type="button">
|
||||
Aufteilen
|
||||
</button>
|
||||
<small className="text-secondary w-100">
|
||||
Raumweise: ganze Räume bleiben zusammen, solange sie passen; Objekte werden nie geteilt. Das Ergebnis ist nur ein Plan.
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Stromkreise des Verteilers" className="revit-assign-pane">
|
||||
<div className="revit-assign-scroll">
|
||||
{tree.sections.map((section) => {
|
||||
const sectionCircuits = circuits.filter((circuit) => circuit.sectionId === section.id);
|
||||
const plannedNew = plan.newCircuits.filter((entry) => entry.sectionId === section.id);
|
||||
const reason = categoryReason(section.category, dragObjects);
|
||||
return (
|
||||
<div className="revit-assign-section" key={section.id}>
|
||||
<h3 className="h6 mb-1">
|
||||
{section.displayName} <small className="text-secondary">{section.prefix}</small>
|
||||
</h3>
|
||||
{sectionCircuits.map((circuit) => renderCircuit(section, circuit))}
|
||||
{plannedNew.map((entry) => {
|
||||
const refKey = circuitRefKey({ kind: "new", key: entry.key });
|
||||
return (
|
||||
<div className="revit-assign-circuit is-new" key={entry.key}>
|
||||
<div
|
||||
className="revit-assign-target"
|
||||
{...dropProps(`${refKey}|circuit`, reason, (ids) => assign(ids, { kind: "new-circuit", key: entry.key }))}
|
||||
>
|
||||
<strong>{newCircuitIdentifiers.get(entry.key)}</strong>
|
||||
<span className="revit-assign-name">neu</span>
|
||||
{occupancy(refKey, 0)}
|
||||
<button
|
||||
aria-label="Geplanten Stromkreis entfernen"
|
||||
className="btn btn-link btn-sm p-0 text-danger"
|
||||
onClick={() => {
|
||||
const ids = planRef.current.assignments
|
||||
.filter((assignment) => assignment.target.kind === "new-circuit" && assignment.target.key === entry.key)
|
||||
.map((assignment) => assignment.objectId);
|
||||
updatePlan(unassignObjects(planRef.current, ids));
|
||||
}}
|
||||
title="Geplanten Stromkreis entfernen"
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{renderPlannedRows(refKey)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="revit-assign-target revit-assign-new"
|
||||
{...dropProps(`new-section:${section.id}|circuit`, reason, (ids) => assignToNewCircuit(ids, section.id))}
|
||||
>
|
||||
<Plus size={14} /> Neuer Stromkreis
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="revit-assign-review">
|
||||
{notice ? <div className="alert alert-info py-2 mb-2">{notice}</div> : null}
|
||||
{error ? <div className="alert alert-danger py-2 mb-2">{error}</div> : null}
|
||||
{uniqueIssues.length > 0 ? (
|
||||
<div className="alert alert-danger py-2 mb-2">
|
||||
{uniqueIssues.map((issue) => <div key={issue}>{issue}</div>)}
|
||||
</div>
|
||||
) : null}
|
||||
{conflicts.length > 0 ? (
|
||||
<div className="alert alert-warning py-2 mb-2">
|
||||
<strong>Abweichende Planungswerte</strong>
|
||||
<table className="table table-sm mb-1 mt-1">
|
||||
<thead>
|
||||
<tr><th>Objekt</th><th>Zeile</th><th>Feld</th><th>Zeilenwert</th><th>Objektwert</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resolved.circuits.flatMap((circuit) =>
|
||||
circuit.rows.flatMap((row) =>
|
||||
row.conflicts.map((conflict) => (
|
||||
<tr key={`${conflict.objectId}-${conflict.field}`}>
|
||||
<td><code className="small">{shortWorksharingId(objectById.get(conflict.objectId)?.worksharingId ?? "")}</code></td>
|
||||
<td>{row.row.kind === "existing" ? rowLabel(row.row.rowId) : circuitLabel(circuit)}</td>
|
||||
<td>{assignmentConflictFieldLabels[conflict.field]}</td>
|
||||
<td>{formatConflictValue(conflict.rowValue)}</td>
|
||||
<td>{formatConflictValue(conflict.objectValue)}</td>
|
||||
</tr>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<label className="form-check mb-0">
|
||||
<input checked={confirmConflicts} className="form-check-input" onChange={(event) => setConfirmConflicts(event.target.checked)} type="checkbox" />
|
||||
<span className="form-check-label">Abweichungen bestätigen – die Werte der Gerätezeile gelten für die zugeordneten Objekte.</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{overLimitCircuits.length > 0 ? (
|
||||
<div className="alert alert-warning py-2 mb-2">
|
||||
<strong>Höchstwert überschritten</strong>
|
||||
<ul className="mb-1">
|
||||
{overLimitCircuits.map((circuit) => (
|
||||
<li key={circuit.refKey}>
|
||||
{circuitLabel(circuit)}: {formatQuantity(circuit.socketQuantityAfter)} von {limit} Steckdosen
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="form-check mb-0">
|
||||
<input checked={confirmOverLimit} className="form-check-input" onChange={(event) => setConfirmOverLimit(event.target.checked)} type="checkbox" />
|
||||
<span className="form-check-label">Überschreitung ausdrücklich bestätigen.</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
function TriStateCheckbox({
|
||||
checked,
|
||||
indeterminate,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
label: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
return (
|
||||
<input
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className="form-check-input mt-0"
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function shortWorksharingId(value: string) {
|
||||
return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
function formatConflictValue(value: string | number | null) {
|
||||
if (value === null) return "–";
|
||||
return typeof value === "number" ? value.toLocaleString("de-DE", { maximumFractionDigits: 3 }) : value;
|
||||
}
|
||||
104
src/frontend/components/revit-linked-objects-dialog.tsx
Normal file
104
src/frontend/components/revit-linked-objects-dialog.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { ExternalProjectObjectDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
/**
|
||||
* Revit objects linked to one device row (requirements §11.8). Single
|
||||
* objects or all objects can be detached explicitly; detached objects take
|
||||
* the current row values as planning values and overrides.
|
||||
*/
|
||||
export function RevitLinkedObjectsDialog({
|
||||
rowLabel,
|
||||
objects,
|
||||
isSaving,
|
||||
onDetach,
|
||||
onClose,
|
||||
}: {
|
||||
rowLabel: string;
|
||||
objects: readonly ExternalProjectObjectDto[];
|
||||
isSaving: boolean;
|
||||
onDetach: (objectIds: string[]) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const allSelected = objects.length > 0 && selected.size === objects.length;
|
||||
|
||||
function toggle(objectId: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(objectId)) next.delete(objectId);
|
||||
else next.add(objectId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={rowLabel}
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onDetach([...selected]);
|
||||
}}
|
||||
submitDisabled={selected.size === 0}
|
||||
submitLabel={selected.size === 0 ? "Auswahl lösen" : `${selected.size} Objekt(e) lösen`}
|
||||
title="Verknüpfte Revit-Objekte"
|
||||
>
|
||||
<p className="small text-secondary">
|
||||
Gelöste Objekte werden wieder unzugeordnet und übernehmen die aktuellen
|
||||
Werte der Gerätezeile als Planungswerte.
|
||||
</p>
|
||||
<table className="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
aria-label="Alle Objekte auswählen"
|
||||
checked={allSelected}
|
||||
className="form-check-input"
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? new Set() : new Set(objects.map((object) => object.id)))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
</th>
|
||||
<th>Worksharing-ID</th>
|
||||
<th>Familie und Typ</th>
|
||||
<th>Raum</th>
|
||||
<th className="text-end">Menge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{objects.map((object) => (
|
||||
<tr key={object.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`${object.worksharingId} auswählen`}
|
||||
checked={selected.has(object.id)}
|
||||
className="form-check-input"
|
||||
onChange={() => toggle(object.id)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</td>
|
||||
<td><code className="small">{object.worksharingId}</code></td>
|
||||
<td>{object.familyAndType}</td>
|
||||
<td>{[object.sourceRoomNumber, object.sourceRoomName].filter(Boolean).join(" ") || "–"}</td>
|
||||
<td className="text-end">{object.planningValues.effectiveQuantity}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
disabled={isSaving || objects.length === 0}
|
||||
onClick={() => void onDetach(objects.map((object) => object.id))}
|
||||
type="button"
|
||||
>
|
||||
Alle {objects.length} lösen
|
||||
</button>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ import type {
|
|||
} from "../types";
|
||||
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`
|
||||
|
|
|
|||
340
src/frontend/utils/revit-assignment-model.ts
Normal file
340
src/frontend/utils/revit-assignment-model.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { nextCircuitIdentifiers } from "../../domain/services/circuit-numbering.service";
|
||||
import type {
|
||||
AssignmentPlanCategory,
|
||||
AssignmentPlanCircuit,
|
||||
AssignmentPlanObject,
|
||||
AssignmentPlanSection,
|
||||
AssignmentPlanState,
|
||||
AssignmentTarget,
|
||||
} from "../../shared/revit/assignment-plan";
|
||||
import { objectGroupKey } from "../../shared/revit/assignment-plan";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
ExternalProjectObjectDto,
|
||||
ExternalProjectObjectsDto,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Adapters between editor DTOs and the shared assignment planning module,
|
||||
* plus the pure view model of the Revit assignment dialog.
|
||||
*/
|
||||
|
||||
const planCategories = new Set<string>(["lighting", "single_phase", "three_phase"]);
|
||||
|
||||
export function isAssignableObject(object: ExternalProjectObjectDto, distributionBoardId: string) {
|
||||
return (
|
||||
object.presenceStatus === "present" &&
|
||||
object.circuitDeviceRowId === null &&
|
||||
object.distributionBoardId === distributionBoardId
|
||||
);
|
||||
}
|
||||
|
||||
export function countObjectsWithoutBoard(objects: ExternalProjectObjectsDto | null) {
|
||||
return (objects?.objects ?? []).filter(
|
||||
(object) =>
|
||||
object.presenceStatus === "present" &&
|
||||
object.circuitDeviceRowId === null &&
|
||||
object.distributionBoardId === null
|
||||
).length;
|
||||
}
|
||||
|
||||
export function toPlanObject(
|
||||
object: ExternalProjectObjectDto,
|
||||
socketDeviceTypeIds: ReadonlySet<string>
|
||||
): AssignmentPlanObject {
|
||||
const category = object.planningValues.category;
|
||||
return {
|
||||
id: object.id,
|
||||
worksharingId: object.worksharingId,
|
||||
familyAndType: object.familyAndType,
|
||||
selectionMarker: object.selectionMarker,
|
||||
deviceTypeId: object.deviceTypeId,
|
||||
roomId: object.roomId,
|
||||
roomNumber: object.sourceRoomNumber,
|
||||
roomName: object.sourceRoomName,
|
||||
linkedProjectDeviceId: object.linkedProjectDeviceId,
|
||||
isSocket: socketDeviceTypeIds.has(object.deviceTypeId),
|
||||
planningValues: {
|
||||
...object.planningValues,
|
||||
category: category && planCategories.has(category) ? category : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssignablePlanObjects(
|
||||
objects: ExternalProjectObjectsDto,
|
||||
distributionBoardId: string
|
||||
): AssignmentPlanObject[] {
|
||||
const socketIds = new Set(objects.socketDeviceTypeIds);
|
||||
return objects.objects
|
||||
.filter((object) => isAssignableObject(object, distributionBoardId))
|
||||
.map((object) => toPlanObject(object, socketIds));
|
||||
}
|
||||
|
||||
export function buildPlanSections(tree: CircuitTreeResponseDto): AssignmentPlanSection[] {
|
||||
return tree.sections.map((section) => ({
|
||||
id: section.id,
|
||||
category: (section.category ?? null) as AssignmentPlanCategory | null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPlanCircuits(
|
||||
tree: CircuitTreeResponseDto,
|
||||
objects: ExternalProjectObjectsDto
|
||||
): AssignmentPlanCircuit[] {
|
||||
const socketIds = new Set(objects.socketDeviceTypeIds);
|
||||
const linkedByRowId = new Map<string, ExternalProjectObjectDto[]>();
|
||||
for (const object of objects.objects) {
|
||||
if (object.circuitDeviceRowId === null) continue;
|
||||
linkedByRowId.set(object.circuitDeviceRowId, [
|
||||
...(linkedByRowId.get(object.circuitDeviceRowId) ?? []),
|
||||
object,
|
||||
]);
|
||||
}
|
||||
return tree.sections.flatMap((section) =>
|
||||
section.circuits.map((circuit) => {
|
||||
const linked = circuit.deviceRows.flatMap((row) => linkedByRowId.get(row.id) ?? []);
|
||||
return {
|
||||
id: circuit.id,
|
||||
sectionId: section.id,
|
||||
category: (section.category ?? null) as AssignmentPlanCategory | null,
|
||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||
displayName: circuit.displayName ?? null,
|
||||
linkedSocketQuantity: linked
|
||||
.filter((object) => socketIds.has(object.deviceTypeId))
|
||||
.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
|
||||
rows: circuit.deviceRows.map((row) => {
|
||||
const rowObjects = linkedByRowId.get(row.id) ?? [];
|
||||
return {
|
||||
id: row.id,
|
||||
circuitId: circuit.id,
|
||||
roomId: row.roomId ?? null,
|
||||
displayName: row.displayName,
|
||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? null,
|
||||
category: row.category ?? null,
|
||||
connectionKind: row.connectionKind ?? null,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi ?? null,
|
||||
costGroup: row.costGroup ?? null,
|
||||
remark: row.remark ?? null,
|
||||
linkedSelectionMarkers: [
|
||||
...new Set(rowObjects.map((object) => object.selectionMarker.trim())),
|
||||
],
|
||||
linkedObjectCount: rowObjects.length,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export interface RevitObjectFilter {
|
||||
search: string;
|
||||
deviceTypeId: string;
|
||||
socketsOnly: boolean;
|
||||
}
|
||||
|
||||
export interface RevitObjectGroupView {
|
||||
key: string;
|
||||
label: string;
|
||||
familyAndTypes: string[];
|
||||
objects: AssignmentPlanObject[];
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface RevitRoomView {
|
||||
key: string;
|
||||
label: string;
|
||||
groups: RevitObjectGroupView[];
|
||||
objectIds: string[];
|
||||
quantity: number;
|
||||
socketQuantity: number;
|
||||
}
|
||||
|
||||
/** Left pane: unplanned objects as room → group → object, natural order. */
|
||||
export function buildRoomTree(
|
||||
objects: readonly AssignmentPlanObject[],
|
||||
plannedObjectIds: ReadonlySet<string>,
|
||||
filter: RevitObjectFilter
|
||||
): RevitRoomView[] {
|
||||
const needle = filter.search.trim().toLocaleLowerCase("de-DE");
|
||||
const visible = objects.filter((object) => {
|
||||
if (plannedObjectIds.has(object.id)) return false;
|
||||
if (filter.socketsOnly && !object.isSocket) return false;
|
||||
if (filter.deviceTypeId && object.deviceTypeId !== filter.deviceTypeId) return false;
|
||||
return (
|
||||
!needle ||
|
||||
[
|
||||
object.roomNumber,
|
||||
object.roomName,
|
||||
object.familyAndType,
|
||||
object.selectionMarker,
|
||||
object.worksharingId,
|
||||
object.planningValues.displayName ?? "",
|
||||
].some((value) => value.toLocaleLowerCase("de-DE").includes(needle))
|
||||
);
|
||||
});
|
||||
const rooms = new Map<string, AssignmentPlanObject[]>();
|
||||
for (const object of visible) {
|
||||
const key = object.roomId ?? (object.roomNumber || object.roomName ? `source:${object.roomNumber}|${object.roomName}` : "");
|
||||
rooms.set(key, [...(rooms.get(key) ?? []), object]);
|
||||
}
|
||||
return [...rooms.entries()]
|
||||
.map(([key, roomObjects]): RevitRoomView => {
|
||||
const first = roomObjects[0]!;
|
||||
const groups = new Map<string, AssignmentPlanObject[]>();
|
||||
for (const object of roomObjects) {
|
||||
const groupKey = objectGroupKey(object);
|
||||
groups.set(groupKey, [...(groups.get(groupKey) ?? []), object]);
|
||||
}
|
||||
return {
|
||||
key: key || "without-room",
|
||||
label: key === "" ? "Ohne Raum" : [first.roomNumber, first.roomName].filter(Boolean).join(" ") || "Raum",
|
||||
groups: [...groups.entries()]
|
||||
.map(([groupKey, groupObjects]) => {
|
||||
const sample = groupObjects[0]!;
|
||||
return {
|
||||
key: groupKey,
|
||||
label:
|
||||
sample.planningValues.displayName ||
|
||||
sample.selectionMarker ||
|
||||
sample.familyAndType,
|
||||
familyAndTypes: [...new Set(groupObjects.map((object) => object.familyAndType))],
|
||||
objects: [...groupObjects].sort((left, right) =>
|
||||
compareNatural(left.familyAndType, right.familyAndType) ||
|
||||
compareNatural(left.worksharingId, right.worksharingId)
|
||||
),
|
||||
quantity: sumQuantity(groupObjects),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => compareNatural(left.label, right.label)),
|
||||
objectIds: roomObjects.map((object) => object.id),
|
||||
quantity: sumQuantity(roomObjects),
|
||||
socketQuantity: sumQuantity(roomObjects.filter((object) => object.isSocket)),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.key === "without-room" || right.key === "without-room") {
|
||||
return left.key === right.key ? 0 : left.key === "without-room" ? 1 : -1;
|
||||
}
|
||||
return compareNatural(left.label, right.label);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ids in visual order, used for Shift+click range selection. */
|
||||
export function flattenRoomTree(rooms: readonly RevitRoomView[]): string[] {
|
||||
return rooms.flatMap((room) => room.groups.flatMap((group) => group.objects.map((object) => object.id)));
|
||||
}
|
||||
|
||||
export function selectRange(
|
||||
orderedIds: readonly string[],
|
||||
anchorId: string | null,
|
||||
targetId: string
|
||||
): string[] {
|
||||
const anchor = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
|
||||
const target = orderedIds.indexOf(targetId);
|
||||
if (anchor < 0 || target < 0) return [targetId];
|
||||
const [from, to] = anchor < target ? [anchor, target] : [target, anchor];
|
||||
return orderedIds.slice(from, to + 1);
|
||||
}
|
||||
|
||||
export function assignObjects(
|
||||
plan: AssignmentPlanState,
|
||||
objectIds: readonly string[],
|
||||
target: AssignmentTarget
|
||||
): AssignmentPlanState {
|
||||
const ids = new Set(objectIds);
|
||||
return {
|
||||
...plan,
|
||||
assignments: [
|
||||
...plan.assignments.filter((assignment) => !ids.has(assignment.objectId)),
|
||||
...objectIds.map((objectId) => ({ objectId, target })),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function unassignObjects(
|
||||
plan: AssignmentPlanState,
|
||||
objectIds: readonly string[]
|
||||
): AssignmentPlanState {
|
||||
const ids = new Set(objectIds);
|
||||
return pruneNewCircuits({
|
||||
...plan,
|
||||
assignments: plan.assignments.filter((assignment) => !ids.has(assignment.objectId)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops planned new circuits that no longer receive any object. */
|
||||
export function pruneNewCircuits(plan: AssignmentPlanState): AssignmentPlanState {
|
||||
const used = new Set(
|
||||
plan.assignments.flatMap((assignment) =>
|
||||
assignment.target.kind === "new-circuit" ? [assignment.target.key] : []
|
||||
)
|
||||
);
|
||||
return { ...plan, newCircuits: plan.newCircuits.filter((entry) => used.has(entry.key)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only assignments whose object is still assignable and whose target
|
||||
* still exists, for example after a reload caused by a revision conflict.
|
||||
*/
|
||||
export function revalidatePlan(
|
||||
plan: AssignmentPlanState,
|
||||
objects: readonly AssignmentPlanObject[],
|
||||
circuits: readonly AssignmentPlanCircuit[],
|
||||
sections: readonly AssignmentPlanSection[]
|
||||
): { plan: AssignmentPlanState; removedCount: number } {
|
||||
const objectIds = new Set(objects.map((object) => object.id));
|
||||
const circuitIds = new Set(circuits.map((circuit) => circuit.id));
|
||||
const rowIds = new Set(circuits.flatMap((circuit) => circuit.rows.map((row) => row.id)));
|
||||
const sectionIds = new Set(sections.map((section) => section.id));
|
||||
const newCircuits = plan.newCircuits.filter((entry) => sectionIds.has(entry.sectionId));
|
||||
const newKeys = new Set(newCircuits.map((entry) => entry.key));
|
||||
const assignments = plan.assignments.filter((assignment) => {
|
||||
if (!objectIds.has(assignment.objectId)) return false;
|
||||
const target = assignment.target;
|
||||
if (target.kind === "row") return rowIds.has(target.rowId);
|
||||
if (target.kind === "circuit") return circuitIds.has(target.circuitId);
|
||||
return newKeys.has(target.key);
|
||||
});
|
||||
return {
|
||||
plan: pruneNewCircuits({ ...plan, newCircuits, assignments }),
|
||||
removedCount: plan.assignments.length - assignments.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** BMK preview for planned new circuits; the server plans them identically. */
|
||||
export function previewNewCircuitIdentifiers(
|
||||
tree: CircuitTreeResponseDto,
|
||||
plan: AssignmentPlanState
|
||||
): Map<string, string> {
|
||||
const preview = new Map<string, string>();
|
||||
for (const section of tree.sections) {
|
||||
const keys = plan.newCircuits.filter((entry) => entry.sectionId === section.id).map((entry) => entry.key);
|
||||
if (keys.length === 0) continue;
|
||||
const identifiers = nextCircuitIdentifiers(
|
||||
section.prefix,
|
||||
section.circuits.map((circuit) => circuit.equipmentIdentifier),
|
||||
keys.length
|
||||
);
|
||||
keys.forEach((key, index) => preview.set(key, identifiers[index]!));
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
export function categoriesOf(objects: readonly AssignmentPlanObject[]) {
|
||||
return new Set(objects.map((object) => object.planningValues.category));
|
||||
}
|
||||
|
||||
export function formatQuantity(value: number) {
|
||||
return Number.isInteger(value) ? String(value) : value.toLocaleString("de-DE", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function sumQuantity(objects: readonly AssignmentPlanObject[]) {
|
||||
return objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
|
||||
}
|
||||
|
||||
function compareNatural(left: string, right: string) {
|
||||
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
|
@ -289,6 +289,7 @@ describe("project settings presentation", () => {
|
|||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
maxSocketsPerCircuit: null,
|
||||
currentRevision: 0,
|
||||
},
|
||||
usedDistributionBoardSupplyTypes: ["SiBe"],
|
||||
|
|
|
|||
157
tests/revit-assignment-model.test.ts
Normal file
157
tests/revit-assignment-model.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
ExternalProjectObjectDto,
|
||||
ExternalProjectObjectsDto,
|
||||
} from "../src/frontend/types.js";
|
||||
import {
|
||||
assignObjects,
|
||||
buildAssignablePlanObjects,
|
||||
buildPlanCircuits,
|
||||
buildPlanSections,
|
||||
buildRoomTree,
|
||||
countObjectsWithoutBoard,
|
||||
flattenRoomTree,
|
||||
previewNewCircuitIdentifiers,
|
||||
revalidatePlan,
|
||||
selectRange,
|
||||
unassignObjects,
|
||||
} from "../src/frontend/utils/revit-assignment-model.js";
|
||||
import { applyExternalObjectAssignmentPlan } from "../src/frontend/utils/api.js";
|
||||
|
||||
function object(id: string, overrides: Partial<ExternalProjectObjectDto> = {}): ExternalProjectObjectDto {
|
||||
return {
|
||||
id, worksharingId: `ws-${id}`, ifcGuid: null, deviceTypeId: "type-socket",
|
||||
familyAndType: "Steckdose: Doppel", selectionMarker: "SD", sourceCircuitIdentifier: "",
|
||||
sourceRoomNumber: "1.01", sourceRoomName: "Büro", roomId: "room-1",
|
||||
distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId: null,
|
||||
presenceStatus: "present",
|
||||
planningValues: {
|
||||
displayName: "Steckdose", category: "single_phase", connectionKind: null, effectiveQuantity: 2,
|
||||
powerPerUnitW: 200, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function objectsDto(objects: ExternalProjectObjectDto[]): ExternalProjectObjectsDto {
|
||||
return {
|
||||
currentRevision: 3, sourceName: "Revit", maxSocketsPerCircuit: 8, socketDeviceTypeIds: ["type-socket"],
|
||||
distributionBoards: [], projectDevices: [], objects,
|
||||
};
|
||||
}
|
||||
|
||||
function tree(): CircuitTreeResponseDto {
|
||||
return {
|
||||
circuitListId: "list-1", distributionBoardId: "board-1", distributionBoardName: "UV 1",
|
||||
currentRevision: 3, singlePhaseVoltageV: 230, threePhaseVoltageV: 400,
|
||||
distributionBoardSimultaneityFactor: 1, distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0, headerComponents: [], footerComponents: [],
|
||||
sections: [{
|
||||
id: "section-2", key: "single", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 20,
|
||||
category: "single_phase", groupNumber: 1, sectionTotalPower: 0, components: [],
|
||||
circuits: [{
|
||||
id: "circuit-1", circuitListId: "list-1", sectionId: "section-2", equipmentIdentifier: "-2F1.7",
|
||||
displayName: "Steckdose", sortOrder: 10, isReserve: false, circuitTotalPower: 0,
|
||||
circuitApparentPower: 0, circuitCosPhi: 1, downstreamBoardLoads: [],
|
||||
deviceRows: [{
|
||||
id: "row-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", category: "single_phase",
|
||||
roomId: "room-1", quantity: 2, manualQuantity: 0, powerPerUnit: 0.2, simultaneityFactor: 1,
|
||||
rowTotalPower: 0.4, externalObjectCount: 1,
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Revit assignment dialog model", () => {
|
||||
it("offers only present, unassigned objects of the board and counts objects without board", () => {
|
||||
const dto = objectsDto([
|
||||
object("free"),
|
||||
object("linked", { circuitDeviceRowId: "row-1" }),
|
||||
object("missing", { presenceStatus: "missing" }),
|
||||
object("other-board", { distributionBoardId: "board-2" }),
|
||||
object("no-board", { distributionBoardId: null }),
|
||||
object("light", { deviceTypeId: "type-light" }),
|
||||
]);
|
||||
const planObjects = buildAssignablePlanObjects(dto, "board-1");
|
||||
assert.deepEqual(planObjects.map((entry) => [entry.id, entry.isSocket]), [["free", true], ["light", false]]);
|
||||
assert.equal(countObjectsWithoutBoard(dto), 1);
|
||||
|
||||
const circuits = buildPlanCircuits(tree(), dto);
|
||||
assert.equal(circuits[0]!.linkedSocketQuantity, 2);
|
||||
assert.deepEqual(circuits[0]!.rows[0]!.linkedSelectionMarkers, ["SD"]);
|
||||
assert.deepEqual(buildPlanSections(tree()), [{ id: "section-2", category: "single_phase" }]);
|
||||
});
|
||||
|
||||
it("builds a room tree without planned objects and selects ranges in visual order", () => {
|
||||
const dto = objectsDto([
|
||||
object("b", { roomId: "room-2", sourceRoomNumber: "1.10" }),
|
||||
object("a2"),
|
||||
object("a1"),
|
||||
object("x", { roomId: null, sourceRoomNumber: "", sourceRoomName: "" }),
|
||||
]);
|
||||
const planObjects = buildAssignablePlanObjects(dto, "board-1");
|
||||
const rooms = buildRoomTree(planObjects, new Set(["a2"]), { search: "", deviceTypeId: "", socketsOnly: false });
|
||||
assert.deepEqual(rooms.map((room) => room.label), ["1.01 Büro", "1.10 Büro", "Ohne Raum"]);
|
||||
const ordered = flattenRoomTree(rooms);
|
||||
assert.deepEqual(ordered, ["a1", "b", "x"]);
|
||||
assert.deepEqual(selectRange(ordered, "a1", "x"), ["a1", "b", "x"]);
|
||||
assert.deepEqual(selectRange(ordered, null, "b"), ["b"]);
|
||||
assert.deepEqual(
|
||||
buildRoomTree(planObjects, new Set(), { search: "1.10", deviceTypeId: "", socketsOnly: false })
|
||||
.flatMap((room) => room.objectIds),
|
||||
["b"]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid assignments after a reload and previews BMKs without filling gaps", () => {
|
||||
const dto = objectsDto([object("a1"), object("a2")]);
|
||||
const planObjects = buildAssignablePlanObjects(dto, "board-1");
|
||||
let plan = assignObjects(
|
||||
{ newCircuits: [{ key: "n1", sectionId: "section-2" }, { key: "n2", sectionId: "section-2" }], assignments: [], newRowChoices: [] },
|
||||
["a1"],
|
||||
{ kind: "new-circuit", key: "n1" }
|
||||
);
|
||||
plan = assignObjects(plan, ["a2"], { kind: "new-circuit", key: "n2" });
|
||||
assert.deepEqual([...previewNewCircuitIdentifiers(tree(), plan).values()], ["-2F1.8", "-2F1.9"]);
|
||||
|
||||
const reloaded = revalidatePlan(plan, planObjects.filter((entry) => entry.id !== "a2"), buildPlanCircuits(tree(), dto), buildPlanSections(tree()));
|
||||
assert.equal(reloaded.removedCount, 1);
|
||||
assert.deepEqual(reloaded.plan.newCircuits.map((entry) => entry.key), ["n1"]);
|
||||
assert.deepEqual(unassignObjects(plan, ["a1", "a2"]).newCircuits, []);
|
||||
});
|
||||
|
||||
it("posts the plan to the assignment-plan route", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({ url: String(input), method: init?.method ?? "GET", body: JSON.parse(String(init?.body)) });
|
||||
return new Response(JSON.stringify({ history: { currentRevision: 4 }, newCircuitIds: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
try {
|
||||
const body = {
|
||||
expectedRevision: 3, circuitListId: "list-1", newCircuits: [],
|
||||
rows: [{ kind: "existing-row" as const, rowId: "row-1", objectIds: ["a1"] }],
|
||||
confirmedConflictObjectIds: [], confirmedOverLimitCircuitKeys: [],
|
||||
};
|
||||
await applyExternalObjectAssignmentPlan("project-1", body);
|
||||
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects/assignment-plan", method: "POST", body }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces the drawer with the assignment dialog in the editor", () => {
|
||||
const editor = readFileSync("src/frontend/components/circuit-tree-editor.tsx", "utf8");
|
||||
const dialog = readFileSync("src/frontend/components/revit-assignment-dialog.tsx", "utf8");
|
||||
assert.doesNotMatch(editor, /revit-object-drawer|x-revit-object-ids|assignExternalProjectObjectsTo/);
|
||||
assert.match(editor, /Revit-Objekte zuordnen/);
|
||||
assert.match(editor, /RevitLinkedObjectsDialog/);
|
||||
assert.match(dialog, /Automatisch aufteilen/);
|
||||
assert.match(dialog, /Überschreitung ausdrücklich bestätigen/);
|
||||
assert.match(dialog, /Abweichungen bestätigen/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue