Add Linear XLSX initial import
This commit is contained in:
parent
c633e78cd4
commit
bfdd048269
20 changed files with 932 additions and 85 deletions
106
src/frontend/components/revit-initial-import-wizard.tsx
Normal file
106
src/frontend/components/revit-initial-import-wizard.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
||||
import type { ExternalInitialImportPlanDto, ProjectHistoryStateDto } from "../types";
|
||||
import { applyExternalInitialImport, planExternalInitialImport } from "../utils/api";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
const steps = ["Vorschau", "Räume", "Verteiler", "Familienregeln", "Projektgeräte", "Bestätigung"];
|
||||
|
||||
export function RevitInitialImportWizard({ projectId, expectedRevision, initialConfiguration, onClose, onImported }: {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
initialConfiguration: ExternalModelConfiguration;
|
||||
onClose: () => void;
|
||||
onImported: (result: { history: ProjectHistoryStateDto; objectCount: number }) => void;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [configuration, setConfiguration] = useState(() => structuredClone(initialConfiguration));
|
||||
const [plan, setPlan] = useState<ExternalInitialImportPlanDto | null>(null);
|
||||
const [roomIds, setRoomIds] = useState<Record<string, string>>( {} );
|
||||
const [boardIds, setBoardIds] = useState<Record<string, string>>( {} );
|
||||
const [deviceIds, setDeviceIds] = useState<Record<string, string>>( {} );
|
||||
const [sourceName, setSourceName] = useState("Revit-Gesamtmodell");
|
||||
const [noRoomBoardId, setNoRoomBoardId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadPlan(nextFile: File, nextConfiguration = configuration) {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const loaded = await planExternalInitialImport(projectId, nextFile, nextConfiguration, expectedRevision);
|
||||
setPlan(loaded);
|
||||
setRoomIds((current) => Object.fromEntries(loaded.sourceRooms.map((room) => [room.sourceRoomKey, current[room.sourceRoomKey] ?? room.suggestedRoomId ?? ""])));
|
||||
setBoardIds((current) => Object.fromEntries(loaded.sourceRooms.map((room) => [room.sourceRoomKey, current[room.sourceRoomKey] ?? ""])));
|
||||
setDeviceIds((current) => Object.fromEntries(loaded.familyGroups.map((family) => [family.familyAndType, current[family.familyAndType] ?? ""])));
|
||||
} catch (value) { setError(value instanceof Error ? value.message : "XLSX-Vorschau fehlgeschlagen."); setPlan(null); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function classifyFamily(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount") {
|
||||
if (!file) return;
|
||||
const deviceTypeId = createClientId();
|
||||
const next = structuredClone(configuration);
|
||||
next.deviceTypes.push({ id: deviceTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } });
|
||||
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule });
|
||||
setConfiguration(next);
|
||||
await loadPlan(file, next);
|
||||
}
|
||||
|
||||
async function assignExistingDeviceType(familyAndType: string, encoded: string) {
|
||||
if (!file || !encoded) return;
|
||||
const separator = encoded.lastIndexOf("|");
|
||||
const deviceTypeId = encoded.slice(0, separator);
|
||||
const quantityRule = encoded.slice(separator + 1) as "one" | "socketCount";
|
||||
const next = structuredClone(configuration);
|
||||
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule });
|
||||
setConfiguration(next);
|
||||
await loadPlan(file, next);
|
||||
}
|
||||
|
||||
async function toggleQuantityRule(familyAndType: string) {
|
||||
if (!file) return;
|
||||
const next = structuredClone(configuration);
|
||||
const rule = next.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === familyAndType);
|
||||
if (!rule) return;
|
||||
rule.quantityRule = rule.quantityRule === "one" ? "socketCount" : "one";
|
||||
setConfiguration(next);
|
||||
await loadPlan(file, next);
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (step < steps.length - 1) { setStep(step + 1); return; }
|
||||
if (!file || !plan) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const result = await applyExternalInitialImport(projectId, file, {
|
||||
expectedRevision,
|
||||
expectedSha256: plan.sha256,
|
||||
sourceName,
|
||||
configuration,
|
||||
objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null,
|
||||
roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: roomIds[room.sourceRoomKey] || null, defaultDistributionBoardId: boardIds[room.sourceRoomKey] || null })),
|
||||
familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: deviceIds[family.familyAndType] || null })),
|
||||
});
|
||||
onImported({ history: result.history, objectCount: result.import.objectCount });
|
||||
} catch (value) { setError(value instanceof Error ? value.message : "Erstimport fehlgeschlagen."); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const blocked = !plan || (step >= 3 && (plan.issueCounts.unknownFamilyAndType > 0 || plan.issueCounts.invalidQuantity > 0));
|
||||
return <FormModal dialogSize="viewport" title="Linear-XLSX-Erstimport" description={`Schritt ${step + 1} von ${steps.length}: ${steps[step]}`} isSaving={busy} onClose={onClose} onSubmit={(event) => void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Erstimport übernehmen" : "Weiter"}>
|
||||
<div className="d-flex gap-2 flex-wrap mb-3">{steps.map((label, index) => <span className={`badge ${index === step ? "text-bg-primary" : index < step ? "text-bg-success" : "text-bg-secondary"}`} key={label}>{index + 1}. {label}</span>)}</div>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{step === 0 ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="initial-xlsx">Linear-XLSX-Datei</label><input accept=".xlsx" className="form-control" id="initial-xlsx" type="file" onChange={(event) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} /></div>{plan ? <><div className="col-md-3"><strong>{plan.objectCount}</strong><div className="text-secondary small">Objekte</div></div><div className="col-md-3"><strong>{plan.sourceRooms.length}</strong><div className="text-secondary small">Quellräume</div></div><div className="col-md-3"><strong>{plan.familyGroups.length}</strong><div className="text-secondary small">Familien</div></div><div className="col-md-3"><strong>{plan.suspectObjectCount}</strong><div className="text-secondary small">übersprungene verdächtige Zeilen</div></div><div className="col-12"><div className="small text-secondary">Unbekannte Familien: {plan.issueCounts.unknownFamilyAndType} · Ungültige Leistungen: {plan.issueCounts.invalidPower} · Ungültige Mengen: {plan.issueCounts.invalidQuantity} · Objekte ohne Raum: {plan.issueCounts.missingRoom}</div></div>{plan.warnings.length ? <div className="col-12"><div className="alert alert-warning mb-0">{plan.warnings.map((warning) => <div key={warning}>{warning}</div>)}</div></div> : null}</> : null}</div> : null}
|
||||
{step === 1 && plan ? <div><p className="text-secondary">Quellräume vorhandenen Projekträumen zuordnen. Neue Räume können vorher in der Gebäudestruktur angelegt werden.</p>{plan.sourceRooms.map((room) => <div className="row align-items-center g-2 mb-2" key={room.sourceRoomKey}><div className="col-md-5"><strong>{room.roomNumber || "–"}</strong> {room.roomName} <span className="badge text-bg-light">{room.objectCount}</span></div><div className="col-md-7"><select className="form-select" value={roomIds[room.sourceRoomKey] ?? ""} onChange={(event) => setRoomIds({ ...roomIds, [room.sourceRoomKey]: event.target.value })}><option value="">Nicht zuordnen</option>{plan.existing.rooms.map((candidate) => <option value={candidate.id} key={candidate.id}>{candidate.roomNumber} · {candidate.roomName}</option>)}</select></div></div>)}</div> : null}
|
||||
{step === 2 && plan ? <div><p className="text-secondary">Der Standardverteiler gilt beim Import nur für unzugeordnete Objekte.</p>{plan.sourceRooms.map((room) => <div className="row align-items-center g-2 mb-2" key={room.sourceRoomKey}><div className="col-md-5">{room.roomNumber || "–"} · {room.roomName}</div><div className="col-md-7"><select className="form-select" value={boardIds[room.sourceRoomKey] ?? ""} onChange={(event) => setBoardIds({ ...boardIds, [room.sourceRoomKey]: event.target.value })}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div>)}{plan.issueCounts.missingRoom > 0 ? <div className="row align-items-center g-2 mt-3"><div className="col-md-5"><strong>Objekte ohne Quellraum</strong> <span className="badge text-bg-light">{plan.issueCounts.missingRoom}</span></div><div className="col-md-7"><select className="form-select" value={noRoomBoardId} onChange={(event) => setNoRoomBoardId(event.target.value)}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div> : null}</div> : null}
|
||||
{step === 3 && plan ? <div><p className="text-secondary">Exakte Familie-und-Typ-Werte werden einer Geräteart und Mengenregel zugeordnet. Ein fehlender GZF startet sichtbar mit 1.</p>{plan.familyGroups.map((family) => <div className="border rounded p-2 mb-2" key={family.familyAndType}><div className="d-flex justify-content-between gap-2"><div><strong>{family.familyAndType}</strong> <span className="badge text-bg-light">{family.objectCount}</span><div className="small text-secondary">{family.classified ? `${family.deviceTypeName} · ${family.quantityRule === "socketCount" ? "Anzahl aus XLSX" : "Menge 1"} · GZF 1, falls nicht gepflegt` : "Noch nicht klassifiziert"}</div></div>{family.classified ? <button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => void toggleQuantityRule(family.familyAndType)}>Mengenregel wechseln</button> : <div className="d-flex gap-2 flex-wrap justify-content-end">{configuration.deviceTypes.length ? <select aria-label={`Vorhandene Geräteart für ${family.familyAndType}`} className="form-select form-select-sm" defaultValue="" onChange={(event) => void assignExistingDeviceType(family.familyAndType, event.target.value)}><option value="">Vorhandene Geräteart wählen …</option>{configuration.deviceTypes.flatMap((type) => [<option value={`${type.id}|one`} key={`${type.id}-one`}>{type.name} · Menge 1</option>, <option value={`${type.id}|socketCount`} key={`${type.id}-socket`}>{type.name} · XLSX-Anzahl</option>])}</select> : null}<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "single_phase", "one")}>Neue 1-phasige Geräteart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "single_phase", "socketCount")}>Neu · XLSX-Anzahl</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "lighting", "one")}>Neue Beleuchtungsart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "three_phase", "one")}>Neue 3-phasige Geräteart</button></div>}</div></div>)}</div> : null}
|
||||
{step === 4 && plan ? <div><p className="text-secondary">Optional kann jede Familie mit einem vorhandenen Projektgerät verknüpft werden.</p>{plan.familyGroups.map((family) => <div className="row align-items-center g-2 mb-2" key={family.familyAndType}><div className="col-md-5">{family.familyAndType}</div><div className="col-md-7"><select className="form-select" value={deviceIds[family.familyAndType] ?? ""} onChange={(event) => setDeviceIds({ ...deviceIds, [family.familyAndType]: event.target.value })}><option value="">Kein Projektgerät</option>{plan.existing.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name} · {device.displayName}</option>)}</select></div></div>)}</div> : null}
|
||||
{step === 5 && plan ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="source-name">Quellenname</label><input className="form-control" id="source-name" value={sourceName} onChange={(event) => setSourceName(event.target.value)} /></div><div className="col-12"><div className="alert alert-info mb-0"><strong>{plan.objectCount} Revit-Objekte</strong> werden importiert. Es werden keine Gerätezeilen, Stromkreise oder BMKs erzeugt. Die Datei wird beim Übernehmen erneut übertragen und geprüft.</div></div></div> : null}
|
||||
{step > 0 ? <button className="btn btn-link px-0 mt-3" disabled={busy} onClick={() => setStep(step - 1)} type="button">Zurück</button> : null}
|
||||
</FormModal>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue