forked from jappel/leistungsbilanz-ts
Replace the Revit drawer with the assignment dialog
- RevitAssignmentDialog: objects as room/group/object with multi-select, filters and drag-and-drop or keyboard assignment onto rows, circuits or new circuits; room-wise automatic split under the socket limit; local plan with review of conflicts and limit overruns, one command on apply and re-check after a revision conflict. - Row badge and context menu list linked objects and detach single objects or all; the context menu opens the dialog for a circuit. - Remove the R7 drawer and direct grid drops; update AGENTS.md, API and architecture docs and mark R11-R14 complete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd45a1caeb
commit
191455c745
12 changed files with 1885 additions and 157 deletions
896
src/frontend/components/revit-assignment-dialog.tsx
Normal file
896
src/frontend/components/revit-assignment-dialog.tsx
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Plus, Split, Undo2, X } from "lucide-react";
|
||||
import React, { type DragEvent, type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
assignmentConflictFieldLabels,
|
||||
circuitRefKey,
|
||||
findRowIssues,
|
||||
newRowChoiceKey,
|
||||
objectGroupKey,
|
||||
resolveAssignmentPlan,
|
||||
socketQuantity,
|
||||
splitSocketsByRoom,
|
||||
toAssignmentPlanRequestRows,
|
||||
type AssignmentPlanCircuit,
|
||||
type AssignmentPlanObject,
|
||||
type AssignmentPlanState,
|
||||
type AssignmentTarget,
|
||||
type ResolvedCircuitPlan,
|
||||
type ResolvedRowAssignment,
|
||||
} from "../../shared/revit/assignment-plan";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
CircuitTreeSectionDto,
|
||||
ExternalProjectObjectsDto,
|
||||
ProjectCommandResultDto,
|
||||
} from "../types";
|
||||
import { ApiRequestError, applyExternalObjectAssignmentPlan } from "../utils/api";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
import {
|
||||
assignObjects,
|
||||
buildAssignablePlanObjects,
|
||||
buildPlanCircuits,
|
||||
buildPlanSections,
|
||||
buildRoomTree,
|
||||
categoriesOf,
|
||||
countObjectsWithoutBoard,
|
||||
flattenRoomTree,
|
||||
formatQuantity,
|
||||
previewNewCircuitIdentifiers,
|
||||
pruneNewCircuits,
|
||||
revalidatePlan,
|
||||
selectRange,
|
||||
unassignObjects,
|
||||
} from "../utils/revit-assignment-model";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
const DRAG_TYPE = "application/x-revit-plan-objects";
|
||||
const emptyPlan: AssignmentPlanState = { newCircuits: [], assignments: [], newRowChoices: [] };
|
||||
|
||||
interface RevitAssignmentDialogProps {
|
||||
projectId: string;
|
||||
tree: CircuitTreeResponseDto;
|
||||
externalObjects: ExternalProjectObjectsDto;
|
||||
initialCircuitId: string | null;
|
||||
getExpectedRevision: () => number;
|
||||
onApplied: (result: ProjectCommandResultDto) => Promise<void>;
|
||||
onReload: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revit assignment dialog (requirements §11.2–11.7). Objects are selected on
|
||||
* the left and dragged (or assigned by keyboard) onto circuits, rows or a new
|
||||
* circuit on the right. Every drop only extends the local plan; "Übernehmen"
|
||||
* writes the complete plan as one project command and one undo step.
|
||||
*/
|
||||
export function RevitAssignmentDialog({
|
||||
projectId,
|
||||
tree,
|
||||
externalObjects,
|
||||
initialCircuitId,
|
||||
getExpectedRevision,
|
||||
onApplied,
|
||||
onReload,
|
||||
onClose,
|
||||
}: RevitAssignmentDialogProps) {
|
||||
const objects = useMemo(
|
||||
() => buildAssignablePlanObjects(externalObjects, tree.distributionBoardId),
|
||||
[externalObjects, tree.distributionBoardId]
|
||||
);
|
||||
const circuits = useMemo(() => buildPlanCircuits(tree, externalObjects), [tree, externalObjects]);
|
||||
const sections = useMemo(() => buildPlanSections(tree), [tree]);
|
||||
const objectById = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
|
||||
const circuitById = useMemo(() => new Map(circuits.map((circuit) => [circuit.id, circuit])), [circuits]);
|
||||
const limit = externalObjects.maxSocketsPerCircuit;
|
||||
const withoutBoardCount = countObjectsWithoutBoard(externalObjects);
|
||||
const deviceTypes = useMemo(() => {
|
||||
const names = new Map<string, string>();
|
||||
for (const object of objects) {
|
||||
if (!names.has(object.deviceTypeId)) {
|
||||
names.set(object.deviceTypeId, object.planningValues.displayName || object.familyAndType);
|
||||
}
|
||||
}
|
||||
return [...names.entries()].sort((left, right) => left[1].localeCompare(right[1], "de"));
|
||||
}, [objects]);
|
||||
|
||||
const [plan, setPlan] = useState<AssignmentPlanState>(emptyPlan);
|
||||
const planRef = useRef(plan);
|
||||
planRef.current = plan;
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
const [anchorId, setAnchorId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [deviceTypeFilter, setDeviceTypeFilter] = useState("");
|
||||
const [socketsOnly, setSocketsOnly] = useState(false);
|
||||
const [collapsedRooms, setCollapsedRooms] = useState<Set<string>>(new Set());
|
||||
const [expandedCircuits, setExpandedCircuits] = useState<Set<string>>(
|
||||
() => new Set(initialCircuitId ? [initialCircuitId] : [])
|
||||
);
|
||||
const [dragIds, setDragIds] = useState<string[]>([]);
|
||||
const [dropKey, setDropKey] = useState<string | null>(null);
|
||||
const [assignTarget, setAssignTarget] = useState(initialCircuitId ? `circuit:${initialCircuitId}` : "");
|
||||
const [splitOpen, setSplitOpen] = useState(false);
|
||||
const [splitSectionId, setSplitSectionId] = useState("");
|
||||
const [splitLimit, setSplitLimit] = useState(limit === null ? "" : String(limit));
|
||||
const [confirmConflicts, setConfirmConflicts] = useState(false);
|
||||
const [confirmOverLimit, setConfirmOverLimit] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const initialCircuitRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// A reload (for example after a revision conflict) keeps every planned
|
||||
// assignment that is still valid and names the removed ones.
|
||||
useEffect(() => {
|
||||
const result = revalidatePlan(planRef.current, objects, circuits, sections);
|
||||
if (result.removedCount > 0) {
|
||||
setPlan(result.plan);
|
||||
setNotice(
|
||||
`${result.removedCount} geplante Zuordnung(en) wurden entfernt, weil sich der Projektstand geändert hat.`
|
||||
);
|
||||
}
|
||||
}, [objects, circuits, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
initialCircuitRef.current?.scrollIntoView({ block: "center" });
|
||||
}, []);
|
||||
|
||||
const resolved = useMemo(
|
||||
() => resolveAssignmentPlan({ objects, circuits, sections, plan, maxSocketsPerCircuit: limit }),
|
||||
[objects, circuits, sections, plan, limit]
|
||||
);
|
||||
const resolvedByRef = useMemo(
|
||||
() => new Map(resolved.circuits.map((circuit) => [circuit.refKey, circuit])),
|
||||
[resolved]
|
||||
);
|
||||
const plannedIds = useMemo(() => new Set(plan.assignments.map((entry) => entry.objectId)), [plan]);
|
||||
const targetByObjectId = useMemo(
|
||||
() => new Map(plan.assignments.map((entry) => [entry.objectId, entry.target])),
|
||||
[plan]
|
||||
);
|
||||
const rooms = useMemo(
|
||||
() => buildRoomTree(objects, plannedIds, { search, deviceTypeId: deviceTypeFilter, socketsOnly }),
|
||||
[objects, plannedIds, search, deviceTypeFilter, socketsOnly]
|
||||
);
|
||||
const orderedIds = useMemo(() => flattenRoomTree(rooms), [rooms]);
|
||||
const visibleIds = useMemo(() => new Set(orderedIds), [orderedIds]);
|
||||
const selectedIds = useMemo(
|
||||
() => [...selection].filter((id) => visibleIds.has(id)),
|
||||
[selection, visibleIds]
|
||||
);
|
||||
const selectedObjects = selectedIds.map((id) => objectById.get(id)!);
|
||||
const newCircuitIdentifiers = useMemo(() => previewNewCircuitIdentifiers(tree, plan), [tree, plan]);
|
||||
const dragObjects = dragIds.flatMap((id) => objectById.get(id) ?? []);
|
||||
const conflicts = resolved.circuits.flatMap((circuit) => circuit.rows.flatMap((row) => row.conflicts));
|
||||
const overLimitCircuits = resolved.circuits.filter((circuit) => circuit.overLimit);
|
||||
const uniqueIssues = [...new Set(resolved.issues)];
|
||||
useEffect(() => setConfirmConflicts(false), [conflicts.length]);
|
||||
useEffect(() => setConfirmOverLimit(false), [overLimitCircuits.length]);
|
||||
|
||||
function updatePlan(next: AssignmentPlanState) {
|
||||
setPlan(pruneNewCircuits(next));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function assign(objectIds: readonly string[], target: AssignmentTarget) {
|
||||
if (objectIds.length === 0) return;
|
||||
updatePlan(assignObjects(planRef.current, objectIds, target));
|
||||
setSelection((current) => new Set([...current].filter((id) => !objectIds.includes(id))));
|
||||
}
|
||||
|
||||
function assignToNewCircuit(objectIds: readonly string[], sectionId: string) {
|
||||
const key = createClientId();
|
||||
updatePlan(assignObjects(
|
||||
{ ...planRef.current, newCircuits: [...planRef.current.newCircuits, { key, sectionId }] },
|
||||
objectIds,
|
||||
{ kind: "new-circuit", key }
|
||||
));
|
||||
setSelection((current) => new Set([...current].filter((id) => !objectIds.includes(id))));
|
||||
}
|
||||
|
||||
function toggleSelection(objectIds: readonly string[], event: React.MouseEvent | React.ChangeEvent) {
|
||||
const shift = "shiftKey" in event && event.shiftKey;
|
||||
if (shift && objectIds.length === 1) {
|
||||
const range = selectRange(orderedIds, anchorId, objectIds[0]!);
|
||||
setSelection((current) => new Set([...current, ...range]));
|
||||
return;
|
||||
}
|
||||
const allSelected = objectIds.every((id) => selection.has(id));
|
||||
setSelection((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of objectIds) {
|
||||
if (allSelected) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (objectIds.length === 1) setAnchorId(objectIds[0]!);
|
||||
}
|
||||
|
||||
function startDrag(event: DragEvent, itemIds: readonly string[]) {
|
||||
const ids = itemIds.every((id) => selection.has(id)) ? selectedIds : [...itemIds];
|
||||
setDragIds(ids);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids));
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
setDragIds([]);
|
||||
setDropKey(null);
|
||||
}
|
||||
|
||||
function categoryReason(category: string | null | undefined, candidates: readonly AssignmentPlanObject[]) {
|
||||
if (!category) return "Die Stromkreisgruppe besitzt keine Kategorie.";
|
||||
const categories = categoriesOf(candidates);
|
||||
if (categories.size !== 1 || !categories.has(category as never)) {
|
||||
return "Kategorie der Revit-Objekte passt nicht zu dieser Stromkreisgruppe.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dropProps(
|
||||
key: string,
|
||||
reason: string | null,
|
||||
onDrop: (ids: string[]) => void
|
||||
) {
|
||||
return {
|
||||
onDragOver: (event: DragEvent) => {
|
||||
if (dragIds.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = reason ? "none" : "move";
|
||||
if (dropKey !== key) setDropKey(key);
|
||||
},
|
||||
onDragLeave: () => setDropKey((current) => (current === key ? null : current)),
|
||||
onDrop: (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const ids = dragIds;
|
||||
endDrag();
|
||||
if (!reason && ids.length > 0) onDrop(ids);
|
||||
},
|
||||
title: dragIds.length > 0 && reason ? reason : undefined,
|
||||
"data-drop-state": dragIds.length === 0 ? undefined : reason ? "invalid" : dropKey === key ? "over" : "valid",
|
||||
};
|
||||
}
|
||||
|
||||
function occupancyLabel(after: number) {
|
||||
return limit === null ? `${formatQuantity(after)} Steckdosen` : `${formatQuantity(after)} / ${limit} Steckdosen`;
|
||||
}
|
||||
|
||||
function occupancy(refKey: string, before: number) {
|
||||
const planned = resolvedByRef.get(refKey);
|
||||
const after = planned?.socketQuantityAfter ?? before;
|
||||
const predicted = dropKey !== null && dropKey.startsWith(`${refKey}|`) ? after + socketQuantity(dragObjects) : after;
|
||||
const over = limit !== null && predicted > limit && predicted > before;
|
||||
return (
|
||||
<span className={`revit-assign-occupancy ${over ? "is-over" : ""}`}>
|
||||
{occupancyLabel(predicted)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function runAutoSplit() {
|
||||
const maximum = Number(splitLimit);
|
||||
if (!Number.isInteger(maximum) || maximum < 1) {
|
||||
setError("Bitte einen Höchstwert als positive ganze Zahl angeben.");
|
||||
return;
|
||||
}
|
||||
if (!splitSectionId) {
|
||||
setError("Bitte eine Stromkreisgruppe für die neuen Stromkreise wählen.");
|
||||
return;
|
||||
}
|
||||
const result = splitSocketsByRoom(selectedObjects, maximum);
|
||||
let next = planRef.current;
|
||||
for (const objectIds of result.circuits) {
|
||||
const key = createClientId();
|
||||
next = assignObjects(
|
||||
{ ...next, newCircuits: [...next.newCircuits, { key, sectionId: splitSectionId }] },
|
||||
objectIds,
|
||||
{ kind: "new-circuit", key }
|
||||
);
|
||||
}
|
||||
updatePlan(next);
|
||||
const planned = new Set(result.circuits.flat());
|
||||
setSelection((current) => new Set([...current].filter((id) => !planned.has(id))));
|
||||
setSplitOpen(false);
|
||||
const hints = [
|
||||
`${result.circuits.length} neue Stromkreise geplant.`,
|
||||
result.skippedObjectIds.length > 0
|
||||
? `${result.skippedObjectIds.length} Objekt(e) sind keine Steckdosen und bleiben ausgewählt.`
|
||||
: "",
|
||||
result.oversizedObjectIds.length > 0
|
||||
? `${result.oversizedObjectIds.length} Objekt(e) überschreiten den Höchstwert allein und erhalten je einen eigenen Stromkreis.`
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
setNotice(hints.join(" "));
|
||||
}
|
||||
|
||||
function assignFromSelect() {
|
||||
if (!assignTarget || selectedIds.length === 0) return;
|
||||
const [kind, id] = [assignTarget.slice(0, assignTarget.indexOf(":")), assignTarget.slice(assignTarget.indexOf(":") + 1)];
|
||||
const candidates = selectedObjects;
|
||||
if (kind === "circuit") {
|
||||
const circuit = circuitById.get(id);
|
||||
const reason = categoryReason(circuit?.category, candidates);
|
||||
if (reason) return setError(reason);
|
||||
assign(selectedIds, { kind: "circuit", circuitId: id });
|
||||
} else if (kind === "planned") {
|
||||
const entry = planRef.current.newCircuits.find((candidate) => candidate.key === id);
|
||||
const section = sections.find((candidate) => candidate.id === entry?.sectionId);
|
||||
const reason = categoryReason(section?.category, candidates);
|
||||
if (reason) return setError(reason);
|
||||
assign(selectedIds, { kind: "new-circuit", key: id });
|
||||
} else {
|
||||
const section = sections.find((candidate) => candidate.id === id);
|
||||
const reason = categoryReason(section?.category, candidates);
|
||||
if (reason) return setError(reason);
|
||||
assignToNewCircuit(selectedIds, id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRowChoice(row: ResolvedRowAssignment) {
|
||||
const groupKeys = [...new Set(row.objectIds.map((id) => objectGroupKey(objectById.get(id)!)))];
|
||||
const keys = groupKeys.map((groupKey) => newRowChoiceKey(row.circuit, groupKey));
|
||||
const current = new Set(planRef.current.newRowChoices);
|
||||
const forceNew = row.row.kind === "existing";
|
||||
for (const key of keys) {
|
||||
if (forceNew) current.add(key);
|
||||
else current.delete(key);
|
||||
}
|
||||
updatePlan({ ...planRef.current, newRowChoices: [...current] });
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitDisabled) return;
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = toAssignmentPlanRequestRows(resolved);
|
||||
const usedKeys = new Set(rows.flatMap((row) => (row.kind === "new-row-in-new-circuit" ? [row.newCircuitKey] : [])));
|
||||
const result = await applyExternalObjectAssignmentPlan(projectId, {
|
||||
expectedRevision: getExpectedRevision(),
|
||||
circuitListId: tree.circuitListId,
|
||||
newCircuits: plan.newCircuits.filter((entry) => usedKeys.has(entry.key)),
|
||||
rows,
|
||||
confirmedConflictObjectIds: confirmConflicts ? resolved.conflictObjectIds : [],
|
||||
confirmedOverLimitCircuitKeys: confirmOverLimit ? resolved.overLimitRefKeys : [],
|
||||
});
|
||||
await onApplied(result);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError && err.status === 409) {
|
||||
setNotice("Der Projektstand wurde zwischenzeitlich geändert und neu geladen. Bitte den Plan prüfen und erneut übernehmen.");
|
||||
await onReload();
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Die Zuordnung konnte nicht übernommen werden.");
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (plan.assignments.length > 0 && !window.confirm("Geplante Zuordnungen verwerfen?")) return;
|
||||
onClose();
|
||||
}
|
||||
|
||||
const submitDisabled =
|
||||
isSaving ||
|
||||
resolved.objectCount === 0 ||
|
||||
uniqueIssues.length > 0 ||
|
||||
(conflicts.length > 0 && !confirmConflicts) ||
|
||||
(overLimitCircuits.length > 0 && !confirmOverLimit);
|
||||
|
||||
const rowLabel = (rowId: string) => {
|
||||
for (const circuit of circuits) {
|
||||
const row = circuit.rows.find((entry) => entry.id === rowId);
|
||||
if (row) return `${circuit.equipmentIdentifier} · ${row.displayName}`;
|
||||
}
|
||||
return "Gerätezeile";
|
||||
};
|
||||
const circuitLabel = (plannedCircuit: ResolvedCircuitPlan) =>
|
||||
plannedCircuit.circuit.kind === "existing"
|
||||
? circuitById.get(plannedCircuit.circuit.circuitId)?.equipmentIdentifier ?? "Stromkreis"
|
||||
: `${newCircuitIdentifiers.get(plannedCircuit.circuit.key) ?? "neu"} (neu)`;
|
||||
|
||||
function renderPlannedRows(refKey: string) {
|
||||
const plannedCircuit = resolvedByRef.get(refKey);
|
||||
if (!plannedCircuit) return null;
|
||||
return (
|
||||
<ul className="revit-assign-planned">
|
||||
{plannedCircuit.rows.map((row) => {
|
||||
const first = objectById.get(row.objectIds[0]!);
|
||||
const fromCircuitDrop = row.objectIds.some((id) => targetByObjectId.get(id)?.kind !== "row");
|
||||
return (
|
||||
<li
|
||||
className="revit-assign-planned-row"
|
||||
draggable
|
||||
key={row.key}
|
||||
onDragEnd={endDrag}
|
||||
onDragStart={(event) => {
|
||||
setDragIds(row.objectIds);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(row.objectIds));
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{row.row.kind === "existing" ? `→ ${rowLabel(row.row.rowId)}` : `Neue Zeile: ${first?.planningValues.displayName || first?.familyAndType || "Revit"}`}
|
||||
<small className="text-secondary ms-1">
|
||||
{first ? [first.roomNumber, first.roomName].filter(Boolean).join(" ") : ""} · {row.objectIds.length} Objekt(e) · Menge {formatQuantity(row.quantity)}
|
||||
</small>
|
||||
</span>
|
||||
<span className="revit-assign-planned-actions">
|
||||
{row.row.kind === "new" && row.matchingRowId ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={() => toggleRowChoice(row)} type="button">
|
||||
In „{rowLabel(row.matchingRowId)}“ übernehmen
|
||||
</button>
|
||||
) : null}
|
||||
{row.row.kind === "existing" && fromCircuitDrop ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={() => toggleRowChoice(row)} type="button">
|
||||
Als neue Zeile
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="Zuordnung zurücknehmen"
|
||||
className="btn btn-link btn-sm p-0 text-danger"
|
||||
onClick={() => updatePlan(unassignObjects(planRef.current, row.objectIds))}
|
||||
title="Zuordnung zurücknehmen"
|
||||
type="button"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCircuit(section: CircuitTreeSectionDto, circuit: AssignmentPlanCircuit) {
|
||||
const expanded = expandedCircuits.has(circuit.id);
|
||||
const reason = categoryReason(section.category, dragObjects);
|
||||
const treeCircuit = section.circuits.find((entry) => entry.id === circuit.id);
|
||||
return (
|
||||
<div
|
||||
className={`revit-assign-circuit ${circuit.id === initialCircuitId ? "is-initial" : ""}`}
|
||||
key={circuit.id}
|
||||
ref={circuit.id === initialCircuitId ? initialCircuitRef : undefined}
|
||||
>
|
||||
<div
|
||||
className="revit-assign-target"
|
||||
{...dropProps(`${circuit.id}|circuit`, reason, (ids) => assign(ids, { kind: "circuit", circuitId: circuit.id }))}
|
||||
>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Zeilen ausblenden" : "Zeilen anzeigen"}
|
||||
className="btn btn-link btn-sm p-0"
|
||||
onClick={() =>
|
||||
setExpandedCircuits((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(circuit.id)) next.delete(circuit.id);
|
||||
else next.add(circuit.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<strong>{circuit.equipmentIdentifier}</strong>
|
||||
<span className="revit-assign-name">{circuit.displayName ?? treeCircuit?.deviceRows[0]?.displayName ?? "Reserve"}</span>
|
||||
{occupancy(circuit.id, circuit.linkedSocketQuantity)}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ul className="revit-assign-rows">
|
||||
{circuit.rows.map((row) => {
|
||||
const rowReason = reason ?? (dragObjects.length > 0 ? findRowIssues(row, circuit.category, dragObjects)[0] ?? null : null);
|
||||
return (
|
||||
<li
|
||||
className="revit-assign-target revit-assign-row"
|
||||
key={row.id}
|
||||
{...dropProps(`${circuit.id}|row:${row.id}`, rowReason, (ids) => assign(ids, { kind: "row", rowId: row.id }))}
|
||||
>
|
||||
<span>{row.displayName}</span>
|
||||
<small className="text-secondary">
|
||||
{row.linkedObjectCount > 0 ? `Revit ${row.linkedObjectCount}` : "manuell"}
|
||||
</small>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{circuit.rows.length === 0 ? <li className="text-secondary small">Keine Gerätezeilen</li> : null}
|
||||
</ul>
|
||||
) : null}
|
||||
{renderPlannedRows(circuit.id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={`${tree.distributionBoardNumber ? `${tree.distributionBoardNumber} · ` : ""}${tree.distributionBoardName} · ${
|
||||
limit === null ? "kein Steckdosen-Höchstwert" : `Höchstwert ${limit} Steckdosen je Stromkreis`
|
||||
}`}
|
||||
dialogSize="viewport"
|
||||
isSaving={isSaving}
|
||||
onClose={requestClose}
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
submitDisabled={submitDisabled}
|
||||
submitLabel={`Übernehmen (${resolved.objectCount} Objekte${resolved.newCircuitCount > 0 ? `, ${resolved.newCircuitCount} neue Stromkreise` : ""})`}
|
||||
title="Revit-Objekte zuordnen"
|
||||
>
|
||||
<div className="revit-assign">
|
||||
<section
|
||||
aria-label="Zuordenbare Revit-Objekte"
|
||||
className="revit-assign-pane"
|
||||
onDragOver={(event) => {
|
||||
if (dragIds.some((id) => plannedIds.has(id))) event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const ids = dragIds.filter((id) => plannedIds.has(id));
|
||||
endDrag();
|
||||
if (ids.length > 0) {
|
||||
event.preventDefault();
|
||||
updatePlan(unassignObjects(planRef.current, ids));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="revit-assign-filters">
|
||||
<input
|
||||
aria-label="Revit-Objekte suchen"
|
||||
className="form-control form-control-sm"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
placeholder="Suche: Raum, Familie, Auswahlkenner, Worksharing-ID"
|
||||
value={search}
|
||||
/>
|
||||
<select
|
||||
aria-label="Geräteart filtern"
|
||||
className="form-select form-select-sm"
|
||||
onChange={(event) => setDeviceTypeFilter(event.target.value)}
|
||||
value={deviceTypeFilter}
|
||||
>
|
||||
<option value="">Alle Gerätearten</option>
|
||||
{deviceTypes.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<label className="form-check form-check-inline mb-0 small">
|
||||
<input
|
||||
checked={socketsOnly}
|
||||
className="form-check-input"
|
||||
onChange={(event) => setSocketsOnly(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="form-check-label">nur Steckdosen</span>
|
||||
</label>
|
||||
</div>
|
||||
{withoutBoardCount > 0 ? (
|
||||
<p className="small text-secondary mb-2">
|
||||
{withoutBoardCount} Objekte ohne Verteiler – auf der Revit-Seite zuordnen.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="revit-assign-scroll">
|
||||
{rooms.map((room) => {
|
||||
const collapsed = collapsedRooms.has(room.key);
|
||||
const roomSelected = room.objectIds.filter((id) => selection.has(id)).length;
|
||||
return (
|
||||
<div className="revit-assign-room" key={room.key}>
|
||||
<div className="revit-assign-room-header" draggable onDragEnd={endDrag} onDragStart={(event) => startDrag(event, room.objectIds)}>
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? `${room.label} aufklappen` : `${room.label} zuklappen`}
|
||||
className="btn btn-link btn-sm p-0"
|
||||
onClick={() =>
|
||||
setCollapsedRooms((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(room.key)) next.delete(room.key);
|
||||
else next.add(room.key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<TriStateCheckbox
|
||||
checked={roomSelected === room.objectIds.length}
|
||||
indeterminate={roomSelected > 0 && roomSelected < room.objectIds.length}
|
||||
label={`Raum ${room.label} auswählen`}
|
||||
onChange={(event) => toggleSelection(room.objectIds, event)}
|
||||
/>
|
||||
<strong>{room.label}</strong>
|
||||
<small className="text-secondary ms-auto">
|
||||
{room.objectIds.length} Objekte{room.socketQuantity > 0 ? ` · ${formatQuantity(room.socketQuantity)} Steckdosen` : ""}
|
||||
</small>
|
||||
</div>
|
||||
{collapsed ? null : room.groups.map((group) => {
|
||||
const ids = group.objects.map((object) => object.id);
|
||||
const groupSelected = ids.filter((id) => selection.has(id)).length;
|
||||
return (
|
||||
<div className="revit-assign-group" key={group.key}>
|
||||
<div className="revit-assign-group-header" draggable onDragEnd={endDrag} onDragStart={(event) => startDrag(event, ids)}>
|
||||
<TriStateCheckbox
|
||||
checked={groupSelected === ids.length}
|
||||
indeterminate={groupSelected > 0 && groupSelected < ids.length}
|
||||
label={`Gruppe ${group.label} auswählen`}
|
||||
onChange={(event) => toggleSelection(ids, event)}
|
||||
/>
|
||||
<span>{group.label}</span>
|
||||
<small className="text-secondary ms-auto">
|
||||
{ids.length} Objekte · Menge {formatQuantity(group.quantity)}
|
||||
</small>
|
||||
</div>
|
||||
{group.objects.map((object) => (
|
||||
<label
|
||||
className={`revit-assign-object ${selection.has(object.id) ? "is-selected" : ""}`}
|
||||
draggable
|
||||
key={object.id}
|
||||
onDragEnd={endDrag}
|
||||
onDragStart={(event) => startDrag(event, [object.id])}
|
||||
>
|
||||
<input
|
||||
checked={selection.has(object.id)}
|
||||
className="form-check-input"
|
||||
onChange={() => undefined}
|
||||
onClick={(event) => toggleSelection([object.id], event)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="revit-assign-family">{object.familyAndType}</span>
|
||||
<span className="badge text-bg-light">{formatQuantity(object.planningValues.effectiveQuantity)}×</span>
|
||||
<code className="small" title={object.worksharingId}>{shortWorksharingId(object.worksharingId)}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rooms.length === 0 ? (
|
||||
<p className="notice muted">
|
||||
{objects.length === 0
|
||||
? "Keine zuordenbaren Revit-Objekte für diesen Verteiler."
|
||||
: plannedIds.size === objects.length
|
||||
? "Alle Objekte sind verplant."
|
||||
: "Keine Objekte für diesen Filter."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="revit-assign-footer">
|
||||
<span className="small">
|
||||
{selectedIds.length} Objekte · {formatQuantity(socketQuantity(selectedObjects))} Steckdosen ·{" "}
|
||||
{new Set(selectedObjects.map((object) => object.roomId ?? object.roomNumber)).size} Räume
|
||||
</span>
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<select
|
||||
aria-label="Ziel für die Auswahl"
|
||||
className="form-select form-select-sm w-auto"
|
||||
onChange={(event) => setAssignTarget(event.target.value)}
|
||||
value={assignTarget}
|
||||
>
|
||||
<option value="">Ziel wählen …</option>
|
||||
{tree.sections.map((section) => (
|
||||
<optgroup key={section.id} label={`${section.displayName} (${section.prefix})`}>
|
||||
{section.circuits.map((circuit) => (
|
||||
<option key={circuit.id} value={`circuit:${circuit.id}`}>
|
||||
{circuit.equipmentIdentifier} {circuit.displayName ?? ""}
|
||||
</option>
|
||||
))}
|
||||
{plan.newCircuits.filter((entry) => entry.sectionId === section.id).map((entry) => (
|
||||
<option key={entry.key} value={`planned:${entry.key}`}>
|
||||
{newCircuitIdentifiers.get(entry.key)} (neu)
|
||||
</option>
|
||||
))}
|
||||
<option value={`new:${section.id}`}>Neuer Stromkreis in {section.displayName}</option>
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={selectedIds.length === 0 || !assignTarget}
|
||||
onClick={assignFromSelect}
|
||||
type="button"
|
||||
>
|
||||
Zuweisen an …
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary btn-with-icon"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => {
|
||||
setSplitOpen((current) => !current);
|
||||
const category = [...categoriesOf(selectedObjects)][0];
|
||||
const section = tree.sections.find((entry) => entry.category === category);
|
||||
if (!splitSectionId && section) setSplitSectionId(section.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Split size={14} />Automatisch aufteilen …
|
||||
</button>
|
||||
</div>
|
||||
{splitOpen ? (
|
||||
<div className="revit-assign-split">
|
||||
<select
|
||||
aria-label="Stromkreisgruppe für neue Stromkreise"
|
||||
className="form-select form-select-sm w-auto"
|
||||
onChange={(event) => setSplitSectionId(event.target.value)}
|
||||
value={splitSectionId}
|
||||
>
|
||||
<option value="">Stromkreisgruppe wählen …</option>
|
||||
{tree.sections
|
||||
.filter((section) => categoryReason(section.category, selectedObjects.filter((object) => object.isSocket)) === null)
|
||||
.map((section) => (
|
||||
<option key={section.id} value={section.id}>{section.displayName} ({section.prefix})</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="small d-flex align-items-center gap-1">
|
||||
max.
|
||||
<input
|
||||
aria-label="Höchstwert Steckdosen je Stromkreis"
|
||||
className="form-control form-control-sm revit-assign-limit"
|
||||
min={1}
|
||||
onChange={(event) => setSplitLimit(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
type="number"
|
||||
value={splitLimit}
|
||||
/>
|
||||
Steckdosen je Stromkreis
|
||||
</label>
|
||||
<button className="btn btn-sm btn-primary" onClick={runAutoSplit} type="button">
|
||||
Aufteilen
|
||||
</button>
|
||||
<small className="text-secondary w-100">
|
||||
Raumweise: ganze Räume bleiben zusammen, solange sie passen; Objekte werden nie geteilt. Das Ergebnis ist nur ein Plan.
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Stromkreise des Verteilers" className="revit-assign-pane">
|
||||
<div className="revit-assign-scroll">
|
||||
{tree.sections.map((section) => {
|
||||
const sectionCircuits = circuits.filter((circuit) => circuit.sectionId === section.id);
|
||||
const plannedNew = plan.newCircuits.filter((entry) => entry.sectionId === section.id);
|
||||
const reason = categoryReason(section.category, dragObjects);
|
||||
return (
|
||||
<div className="revit-assign-section" key={section.id}>
|
||||
<h3 className="h6 mb-1">
|
||||
{section.displayName} <small className="text-secondary">{section.prefix}</small>
|
||||
</h3>
|
||||
{sectionCircuits.map((circuit) => renderCircuit(section, circuit))}
|
||||
{plannedNew.map((entry) => {
|
||||
const refKey = circuitRefKey({ kind: "new", key: entry.key });
|
||||
return (
|
||||
<div className="revit-assign-circuit is-new" key={entry.key}>
|
||||
<div
|
||||
className="revit-assign-target"
|
||||
{...dropProps(`${refKey}|circuit`, reason, (ids) => assign(ids, { kind: "new-circuit", key: entry.key }))}
|
||||
>
|
||||
<strong>{newCircuitIdentifiers.get(entry.key)}</strong>
|
||||
<span className="revit-assign-name">neu</span>
|
||||
{occupancy(refKey, 0)}
|
||||
<button
|
||||
aria-label="Geplanten Stromkreis entfernen"
|
||||
className="btn btn-link btn-sm p-0 text-danger"
|
||||
onClick={() => {
|
||||
const ids = planRef.current.assignments
|
||||
.filter((assignment) => assignment.target.kind === "new-circuit" && assignment.target.key === entry.key)
|
||||
.map((assignment) => assignment.objectId);
|
||||
updatePlan(unassignObjects(planRef.current, ids));
|
||||
}}
|
||||
title="Geplanten Stromkreis entfernen"
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{renderPlannedRows(refKey)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="revit-assign-target revit-assign-new"
|
||||
{...dropProps(`new-section:${section.id}|circuit`, reason, (ids) => assignToNewCircuit(ids, section.id))}
|
||||
>
|
||||
<Plus size={14} /> Neuer Stromkreis
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="revit-assign-review">
|
||||
{notice ? <div className="alert alert-info py-2 mb-2">{notice}</div> : null}
|
||||
{error ? <div className="alert alert-danger py-2 mb-2">{error}</div> : null}
|
||||
{uniqueIssues.length > 0 ? (
|
||||
<div className="alert alert-danger py-2 mb-2">
|
||||
{uniqueIssues.map((issue) => <div key={issue}>{issue}</div>)}
|
||||
</div>
|
||||
) : null}
|
||||
{conflicts.length > 0 ? (
|
||||
<div className="alert alert-warning py-2 mb-2">
|
||||
<strong>Abweichende Planungswerte</strong>
|
||||
<table className="table table-sm mb-1 mt-1">
|
||||
<thead>
|
||||
<tr><th>Objekt</th><th>Zeile</th><th>Feld</th><th>Zeilenwert</th><th>Objektwert</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resolved.circuits.flatMap((circuit) =>
|
||||
circuit.rows.flatMap((row) =>
|
||||
row.conflicts.map((conflict) => (
|
||||
<tr key={`${conflict.objectId}-${conflict.field}`}>
|
||||
<td><code className="small">{shortWorksharingId(objectById.get(conflict.objectId)?.worksharingId ?? "")}</code></td>
|
||||
<td>{row.row.kind === "existing" ? rowLabel(row.row.rowId) : circuitLabel(circuit)}</td>
|
||||
<td>{assignmentConflictFieldLabels[conflict.field]}</td>
|
||||
<td>{formatConflictValue(conflict.rowValue)}</td>
|
||||
<td>{formatConflictValue(conflict.objectValue)}</td>
|
||||
</tr>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<label className="form-check mb-0">
|
||||
<input checked={confirmConflicts} className="form-check-input" onChange={(event) => setConfirmConflicts(event.target.checked)} type="checkbox" />
|
||||
<span className="form-check-label">Abweichungen bestätigen – die Werte der Gerätezeile gelten für die zugeordneten Objekte.</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{overLimitCircuits.length > 0 ? (
|
||||
<div className="alert alert-warning py-2 mb-2">
|
||||
<strong>Höchstwert überschritten</strong>
|
||||
<ul className="mb-1">
|
||||
{overLimitCircuits.map((circuit) => (
|
||||
<li key={circuit.refKey}>
|
||||
{circuitLabel(circuit)}: {formatQuantity(circuit.socketQuantityAfter)} von {limit} Steckdosen
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="form-check mb-0">
|
||||
<input checked={confirmOverLimit} className="form-check-input" onChange={(event) => setConfirmOverLimit(event.target.checked)} type="checkbox" />
|
||||
<span className="form-check-label">Überschreitung ausdrücklich bestätigen.</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
function TriStateCheckbox({
|
||||
checked,
|
||||
indeterminate,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
label: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
return (
|
||||
<input
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className="form-check-input mt-0"
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function shortWorksharingId(value: string) {
|
||||
return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
function formatConflictValue(value: string | number | null) {
|
||||
if (value === null) return "–";
|
||||
return typeof value === "number" ? value.toLocaleString("de-DE", { maximumFractionDigits: 3 }) : value;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue