Add Linear XLSX initial import

This commit is contained in:
Julian Appel 2026-09-30 09:50:58 +02:00
parent c633e78cd4
commit bfdd048269
20 changed files with 932 additions and 85 deletions

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