forked from jappel/leistungsbilanz-ts
- 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>
896 lines
39 KiB
TypeScript
896 lines
39 KiB
TypeScript
"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;
|
||
}
|