"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; onReload: () => Promise; 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(); 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(emptyPlan); const planRef = useRef(plan); planRef.current = plan; const [selection, setSelection] = useState>(new Set()); const [anchorId, setAnchorId] = useState(null); const [search, setSearch] = useState(""); const [deviceTypeFilter, setDeviceTypeFilter] = useState(""); const [socketsOnly, setSocketsOnly] = useState(false); const [collapsedRooms, setCollapsedRooms] = useState>(new Set()); const [expandedCircuits, setExpandedCircuits] = useState>( () => new Set(initialCircuitId ? [initialCircuitId] : []) ); const [dragIds, setDragIds] = useState([]); const [dropKey, setDropKey] = useState(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(null); const [error, setError] = useState(null); const [isSaving, setIsSaving] = useState(false); const initialCircuitRef = useRef(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 ( {occupancyLabel(predicted)} ); } 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) { 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 (
    {plannedCircuit.rows.map((row) => { const first = objectById.get(row.objectIds[0]!); const fromCircuitDrop = row.objectIds.some((id) => targetByObjectId.get(id)?.kind !== "row"); return (
  • { setDragIds(row.objectIds); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(row.objectIds)); }} > {row.row.kind === "existing" ? `→ ${rowLabel(row.row.rowId)}` : `Neue Zeile: ${first?.planningValues.displayName || first?.familyAndType || "Revit"}`} {first ? [first.roomNumber, first.roomName].filter(Boolean).join(" ") : ""} · {row.objectIds.length} Objekt(e) · Menge {formatQuantity(row.quantity)} {row.row.kind === "new" && row.matchingRowId ? ( ) : null} {row.row.kind === "existing" && fromCircuitDrop ? ( ) : null}
  • ); })}
); } 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 (
assign(ids, { kind: "circuit", circuitId: circuit.id }))} > {circuit.equipmentIdentifier} {circuit.displayName ?? treeCircuit?.deviceRows[0]?.displayName ?? "Reserve"} {occupancy(circuit.id, circuit.linkedSocketQuantity)}
{expanded ? (
    {circuit.rows.map((row) => { const rowReason = reason ?? (dragObjects.length > 0 ? findRowIssues(row, circuit.category, dragObjects)[0] ?? null : null); return (
  • assign(ids, { kind: "row", rowId: row.id }))} > {row.displayName} {row.linkedObjectCount > 0 ? `Revit ${row.linkedObjectCount}` : "manuell"}
  • ); })} {circuit.rows.length === 0 ?
  • Keine Gerätezeilen
  • : null}
) : null} {renderPlannedRows(circuit.id)}
); } return ( void handleSubmit(event)} submitDisabled={submitDisabled} submitLabel={`Übernehmen (${resolved.objectCount} Objekte${resolved.newCircuitCount > 0 ? `, ${resolved.newCircuitCount} neue Stromkreise` : ""})`} title="Revit-Objekte zuordnen" >
{ 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)); } }} >
setSearch(event.target.value)} onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} placeholder="Suche: Raum, Familie, Auswahlkenner, Worksharing-ID" value={search} />
{withoutBoardCount > 0 ? (

{withoutBoardCount} Objekte ohne Verteiler – auf der Revit-Seite zuordnen.

) : null}
{rooms.map((room) => { const collapsed = collapsedRooms.has(room.key); const roomSelected = room.objectIds.filter((id) => selection.has(id)).length; return (
startDrag(event, room.objectIds)}> 0 && roomSelected < room.objectIds.length} label={`Raum ${room.label} auswählen`} onChange={(event) => toggleSelection(room.objectIds, event)} /> {room.label} {room.objectIds.length} Objekte{room.socketQuantity > 0 ? ` · ${formatQuantity(room.socketQuantity)} Steckdosen` : ""}
{collapsed ? null : room.groups.map((group) => { const ids = group.objects.map((object) => object.id); const groupSelected = ids.filter((id) => selection.has(id)).length; return (
startDrag(event, ids)}> 0 && groupSelected < ids.length} label={`Gruppe ${group.label} auswählen`} onChange={(event) => toggleSelection(ids, event)} /> {group.label} {ids.length} Objekte · Menge {formatQuantity(group.quantity)}
{group.objects.map((object) => ( ))}
); })}
); })} {rooms.length === 0 ? (

{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."}

) : null}
{selectedIds.length} Objekte · {formatQuantity(socketQuantity(selectedObjects))} Steckdosen ·{" "} {new Set(selectedObjects.map((object) => object.roomId ?? object.roomNumber)).size} Räume
{splitOpen ? (
Raumweise: ganze Räume bleiben zusammen, solange sie passen; Objekte werden nie geteilt. Das Ergebnis ist nur ein Plan.
) : null}
{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 (

{section.displayName} {section.prefix}

{sectionCircuits.map((circuit) => renderCircuit(section, circuit))} {plannedNew.map((entry) => { const refKey = circuitRefKey({ kind: "new", key: entry.key }); return (
assign(ids, { kind: "new-circuit", key: entry.key }))} > {newCircuitIdentifiers.get(entry.key)} neu {occupancy(refKey, 0)}
{renderPlannedRows(refKey)}
); })}
assignToNewCircuit(ids, section.id))} > Neuer Stromkreis
); })}
{notice ?
{notice}
: null} {error ?
{error}
: null} {uniqueIssues.length > 0 ? (
{uniqueIssues.map((issue) =>
{issue}
)}
) : null} {conflicts.length > 0 ? (
Abweichende Planungswerte {resolved.circuits.flatMap((circuit) => circuit.rows.flatMap((row) => row.conflicts.map((conflict) => ( )) ) )}
ObjektZeileFeldZeilenwertObjektwert
{shortWorksharingId(objectById.get(conflict.objectId)?.worksharingId ?? "")} {row.row.kind === "existing" ? rowLabel(row.row.rowId) : circuitLabel(circuit)} {assignmentConflictFieldLabels[conflict.field]} {formatConflictValue(conflict.rowValue)} {formatConflictValue(conflict.objectValue)}
) : null} {overLimitCircuits.length > 0 ? (
Höchstwert überschritten
    {overLimitCircuits.map((circuit) => (
  • {circuitLabel(circuit)}: {formatQuantity(circuit.socketQuantityAfter)} von {limit} Steckdosen
  • ))}
) : null}
); } function TriStateCheckbox({ checked, indeterminate, label, onChange, }: { checked: boolean; indeterminate: boolean; label: string; onChange: (event: React.ChangeEvent) => void; }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]); return ( ); } 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; }