leistungsbilanz-ts/src/frontend/components/revit-follow-up-import-wizard.tsx

91 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, type FormEvent } from "react";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ExternalFollowUpImportPlanDto, ProjectHistoryStateDto } from "../types";
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../utils/api";
import { createClientId } from "../utils/client-id";
import { FormModal } from "./form-modal";
const steps = ["Vorschau", "Quelländerungen", "Neue Räume", "Familienregeln", "Projektgeräte", "Bestätigung"];
const fieldLabels: Record<string, string> = { ifcGuid: "IfcGUID", roomNumber: "Raumnummer", roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner", socketCount: "Anzahl", sourceRemark: "Bemerkung" };
export function RevitFollowUpImportWizard({ 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<ExternalFollowUpImportPlanDto | null>(null);
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
const [roomIds, setRoomIds] = useState<Record<string, string>>({});
const [boardIds, setBoardIds] = useState<Record<string, string>>({});
const [deviceIds, setDeviceIds] = useState<Record<string, string>>({});
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 planExternalFollowUpImport(projectId, nextFile, nextConfiguration, expectedRevision);
setPlan(loaded);
setAccepted((current) => Object.fromEntries(loaded.changes.map((change) => [change.objectId, current[change.objectId] ?? true])));
setRoomIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => { const room = loaded.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return [key, current[key] ?? room.suggestedRoomId ?? ""]; })));
setBoardIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => [key, current[key] ?? ""])));
setDeviceIds((current) => Object.fromEntries(loaded.newFamilyAndTypes.map((family) => [family, current[family] ?? ""])));
} catch (value) { setError(value instanceof Error ? value.message : "Folgeimport-Vorschau fehlgeschlagen."); setPlan(null); }
finally { setBusy(false); }
}
async function addRule(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount", deviceTypeId?: string) {
if (!file) return;
const next = structuredClone(configuration);
const targetTypeId = deviceTypeId ?? createClientId();
if (!deviceTypeId) next.deviceTypes.push({ id: targetTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } });
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId: targetTypeId, 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 applyExternalFollowUpImport(projectId, file, {
expectedRevision, expectedSha256: plan.sha256, configuration, objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null,
sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: accepted[change.objectId] ?? false })),
roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: roomIds[sourceRoomKey] || null, defaultDistributionBoardId: boardIds[sourceRoomKey] || null })),
familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: deviceIds[familyAndType] || null })),
});
onImported({ history: result.history, objectCount: result.import.objectCount });
} catch (value) { setError(value instanceof Error ? value.message : "Folgeimport fehlgeschlagen."); }
finally { setBusy(false); }
}
const relevantUnknownFamilies = plan?.familyGroups.filter((family) => !family.classified && (plan.newFamilyAndTypes.includes(family.familyAndType) || plan.changes.some((change) => accepted[change.objectId] && change.familyAndType === family.familyAndType))) ?? [];
const relevantInvalidQuantity = plan?.objects.some((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))) ?? false;
const invalidQuantityFamilies = plan ? [...new Set(plan.objects.filter((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))).map((object) => object.sourceValues.familyAndType))] : [];
const blocked = !plan || relevantUnknownFamilies.length > 0 || relevantInvalidQuantity;
return <FormModal dialogSize="viewport" title="Linear-XLSX-Folgeimport" 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 ? "Folgeimport ü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="follow-up-xlsx">Vollständige Linear-XLSX-Datei</label><input accept=".xlsx" className="form-control" id="follow-up-xlsx" type="file" onChange={(event) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} /></div>{plan ? <><Count label="Neu" value={plan.newObjectCount} /><Count label="Geändert" value={plan.changedObjectCount} /><Count label="Unverändert" value={plan.unchangedObjectCount} /><Count label="Fehlend" value={plan.missingObjectCount} /><div className="col-12 text-secondary small">{plan.returnDeviationCount} Objekte enthalten Rückgabefelder, die in Revit noch nicht aktualisiert sind.</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">Nur fachliche Quelländerungen benötigen eine Entscheidung. Abweichende Rückgabefelder werden lediglich als „in Revit noch nicht aktualisiert“ gemeldet.</p>{plan.changes.length ? plan.changes.map((change) => <label className="border rounded p-2 mb-2 d-flex gap-2" key={change.objectId}><input checked={accepted[change.objectId] ?? true} type="checkbox" onChange={(event) => setAccepted({ ...accepted, [change.objectId]: event.target.checked })} /><span><strong>{change.familyAndType}</strong> · {change.worksharingId}<br /><span className="small text-secondary">{change.changedFields.map((field) => fieldLabels[field] ?? field).join(", ")}{change.previousQuantity !== change.suggestedQuantity ? ` · Menge ${change.previousQuantity} → ${change.suggestedQuantity}` : ""}</span></span></label>) : <div className="alert alert-info">Keine fachlichen Quelländerungen.</div>}{plan.missingObjects.length ? <div className="alert alert-warning">{plan.missingObjects.length} fehlende Objekte werden markiert. Links und Mengen bleiben bestehen.</div> : null}</div> : null}
{step === 2 && plan ? <div><p className="text-secondary">Nur neue, tatsächlich übernommene Quellräume benötigen eine Zuordnung.</p>{plan.newSourceRoomKeys.map((key) => { const room = plan.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return <div className="row g-2 mb-2" key={key}><div className="col-md-4"><strong>{room.roomNumber || "–"}</strong> {room.roomName}</div><div className="col-md-4"><select className="form-select" value={roomIds[key] ?? ""} onChange={(event) => setRoomIds({ ...roomIds, [key]: event.target.value })}><option value="">Kein Projektraum</option>{plan.existing.rooms.map((candidate) => <option value={candidate.id} key={candidate.id}>{candidate.roomNumber} · {candidate.roomName}</option>)}</select></div><div className="col-md-4"><select className="form-select" value={boardIds[key] ?? ""} onChange={(event) => setBoardIds({ ...boardIds, [key]: 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>; })}<label className="form-label">Standardverteiler für neue Objekte ohne Raum<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></label></div> : null}
{step === 3 && plan ? <div><p className="text-secondary">Unbekannte Familien müssen nur für neue Objekte oder akzeptierte Familienänderungen klassifiziert werden.</p>{relevantUnknownFamilies.map((family) => <div className="border rounded p-2 mb-2" key={family.familyAndType}><strong>{family.familyAndType}</strong><div className="d-flex gap-2 flex-wrap mt-2">{configuration.deviceTypes.flatMap((type) => [<button className="btn btn-sm btn-outline-secondary" key={`${type.id}-one`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "one", type.id)}>{type.name} · Menge 1</button>, <button className="btn btn-sm btn-outline-secondary" key={`${type.id}-socket`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "socketCount", type.id)}>{type.name} · XLSX-Anzahl</button>])}<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "one")}>Neue 1-phasige Geräteart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "socketCount")}>Neu · XLSX-Anzahl</button></div></div>)}{!relevantUnknownFamilies.length ? <div className="alert alert-success">Alle benötigten Familien sind klassifiziert.</div> : null}{relevantInvalidQuantity ? <div className="alert alert-danger">Mindestens eine übernommene Mengenangabe ist ungültig.{invalidQuantityFamilies.map((family) => <button className="btn btn-sm btn-outline-danger ms-2" key={family} type="button" onClick={() => void toggleQuantityRule(family)}>Mengenregel für {family} wechseln</button>)}</div> : null}</div> : null}
{step === 4 && plan ? <div><p className="text-secondary">Neue Revit-Objekte können familienweise mit einem vorhandenen Projektgerät verknüpft werden.</p>{plan.newFamilyAndTypes.map((family) => <div className="row g-2 align-items-center mb-2" key={family}><div className="col-md-5">{family}</div><div className="col-md-7"><select className="form-select" value={deviceIds[family] ?? ""} onChange={(event) => setDeviceIds({ ...deviceIds, [family]: 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="alert alert-info"><strong>{plan.objectCount} Objekte im vollständigen Import.</strong><br />Neu: {plan.newObjectCount}, fachlich geändert: {plan.changedObjectCount}, fehlend: {plan.missingObjectCount}. Fehlende Objekte werden nicht gelöst oder gelöscht. Apply liest die Datei erneut; Undo und Redo verwenden ausschließlich die gespeicherten Zustände.</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>;
}
function Count({ label, value }: { label: string; value: number }) { return <div className="col-6 col-md-3"><strong>{value}</strong><div className="text-secondary small">{label}</div></div>; }