73 lines
9.7 KiB
TypeScript
73 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import type { ExternalProjectObjectsDto } from "../types";
|
|
import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
|
|
|
const rowHeight = 44;
|
|
const viewportHeight = 520;
|
|
|
|
export function RevitObjectTable({ projectId, onRevision }: { projectId: string; onRevision: (revision: number) => void }) {
|
|
const [data, setData] = useState<ExternalProjectObjectsDto | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [boardFilter, setBoardFilter] = useState("all");
|
|
const [assignmentFilter, setAssignmentFilter] = useState("all");
|
|
const [sort, setSort] = useState<"family" | "room" | "worksharing">("family");
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const [bulkBoard, setBulkBoard] = useState("");
|
|
const [bulkDevice, setBulkDevice] = useState("");
|
|
const [power, setPower] = useState("");
|
|
const [gzf, setGzf] = useState("");
|
|
const [planningField, setPlanningField] = useState("");
|
|
const [planningValue, setPlanningValue] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } }
|
|
useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const rows = useMemo(() => {
|
|
if (!data) return [];
|
|
const needle = search.trim().toLocaleLowerCase("de-DE");
|
|
return data.objects.filter((object) => {
|
|
if (boardFilter !== "all" && (boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== boardFilter)) return false;
|
|
if (assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false;
|
|
if (assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false;
|
|
return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle));
|
|
}).sort((a, b) => {
|
|
const left = sort === "family" ? a.familyAndType : sort === "room" ? `${a.sourceRoomNumber}\u0000${a.sourceRoomName}` : a.worksharingId;
|
|
const right = sort === "family" ? b.familyAndType : sort === "room" ? `${b.sourceRoomNumber}\u0000${b.sourceRoomName}` : b.worksharingId;
|
|
return left.localeCompare(right, "de", { numeric: true }) || a.worksharingId.localeCompare(b.worksharingId);
|
|
});
|
|
}, [data, search, boardFilter, assignmentFilter, sort]);
|
|
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
|
|
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
|
|
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
|
|
|
|
async function applyBulk() {
|
|
if (!data || selected.size === 0) return;
|
|
const patch: Record<string, unknown> = {};
|
|
if (bulkBoard !== "") patch.distributionBoardId = bulkBoard === "none" ? null : bulkBoard;
|
|
if (bulkDevice !== "") patch.linkedProjectDeviceId = bulkDevice === "none" ? null : bulkDevice;
|
|
if (power !== "") patch.powerPerUnitW = Number(power.replace(",", "."));
|
|
if (gzf !== "") patch.simultaneityFactor = Number(gzf.replace(",", "."));
|
|
if (planningField) {
|
|
patch[planningField] = ["effectiveQuantity", "cosPhi"].includes(planningField)
|
|
? Number(planningValue.replace(",", "."))
|
|
: planningValue === "" ? null : planningValue;
|
|
}
|
|
if (Object.keys(patch).length === 0) { setError("Mindestens eine Sammeländerung auswählen."); return; }
|
|
setSaving(true); setError(null);
|
|
try { const result = await updateExternalProjectObjects(projectId, data.currentRevision, [...selected], patch); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); }
|
|
catch (value) { setError(value instanceof Error ? value.message : "Sammeländerung fehlgeschlagen."); }
|
|
finally { setSaving(false); }
|
|
}
|
|
|
|
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
|
|
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
|
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} disabled={linked} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
|
|
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
|
|
</div></section>;
|
|
}
|