Replace the Revit drawer with the assignment dialog

- RevitAssignmentDialog: objects as room/group/object with multi-select,
  filters and drag-and-drop or keyboard assignment onto rows, circuits or
  new circuits; room-wise automatic split under the socket limit; local
  plan with review of conflicts and limit overruns, one command on apply
  and re-check after a revision conflict.
- Row badge and context menu list linked objects and detach single
  objects or all; the context menu opens the dialog for a circuit.
- Remove the R7 drawer and direct grid drops; update AGENTS.md, API and
  architecture docs and mark R11-R14 complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 21:39:27 +01:00
parent fd45a1caeb
commit 191455c745
12 changed files with 1885 additions and 157 deletions

View file

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