leistungsbilanz-ts/src/frontend/components/revit-assignment-dialog.tsx
Julian Appel 191455c745 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>
2026-10-30 21:39:27 +01:00

896 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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