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

View file

@ -10,10 +10,6 @@ import type {
ProtectionDeviceType,
RcdType,
} from "../shared/constants/protection-device";
import type {
ExternalCsvConfiguration,
ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts";
import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration";
export interface ExternalModelConfigurationSnapshotDto {
@ -22,44 +18,7 @@ export interface ExternalModelConfigurationSnapshotDto {
configuration: ExternalModelConfiguration;
}
export interface ExternalCsvConfigurationSnapshotDto {
id: string;
projectId: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
}
export interface ExternalCsvPreviewObjectDto {
rowNumber: number;
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export interface ExternalCsvPreviewDto {
fileName: string;
byteLength: number;
sha256: string;
dialect: ExternalCsvDialect;
headerRowNumber: number;
headerColumns: string[];
rowCount: number;
objectCount: number;
passthroughCount: number;
nonEmptyPassthroughCount: number;
suspectObjectCount: number;
objects: ExternalCsvPreviewObjectDto[];
suspectRowNumbers: number[];
}
export interface ExternalInitialImportPlanDto {
configurationVersion: number;
fileName: string;
sha256: string;
byteLength: number;
@ -68,6 +27,7 @@ export interface ExternalInitialImportPlanDto {
passthroughCount: number;
suspectObjectCount: number;
suspectRowNumbers: number[];
warnings: string[];
sourceRooms: Array<{
sourceRoomKey: string;
roomNumber: string;
@ -81,9 +41,9 @@ export interface ExternalInitialImportPlanDto {
familyAndType: string;
objectCount: number;
classified: boolean;
category: string | null;
connectionKind: string | null;
internalDeviceType: string | null;
deviceTypeId: string | null;
deviceTypeName: string | null;
quantityRule: "one" | "socketCount" | null;
}>;
issueCounts: {
unknownFamilyAndType: number;
@ -91,24 +51,7 @@ export interface ExternalInitialImportPlanDto {
invalidQuantity: number;
missingRoom: number;
};
objects: Array<{
ifcGuid: string;
sourceRoomKey: string | null;
sourceValues: ExternalCsvPreviewObjectDto;
suggestedPlanningValues: {
displayName: string | null;
internalDeviceType: string | null;
category: CircuitGroupCategory | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
simultaneityFactor: number;
cosPhi: number | null;
costGroup: string | null;
remark: string | null;
};
issues: string[];
}>;
objects: Array<{ worksharingId: string; ifcGuid: string | null; sourceRoomKey: string | null; sourceValues: { familyAndType: string }; suggestedPlanningValues: { simultaneityFactor: number; effectiveQuantity: number }; issues: string[] }>;
existing: {
floors: Array<{ id: string; name: string; sortOrder: number }>;
rooms: Array<{
@ -135,11 +78,12 @@ export interface ExternalInitialImportPlanDto {
export interface ApplyExternalInitialImportInput {
expectedRevision: number;
expectedConfigurationVersion: number;
expectedSha256: string;
fileName: string;
contentBase64: string;
sourceName: string;
configuration: ExternalModelConfiguration;
objectsWithoutRoomDefaultDistributionBoardId: string | null;
roomDecisions: Array<{
sourceRoomKey: string;
roomId: string | null;
@ -151,6 +95,14 @@ export interface ApplyExternalInitialImportInput {
}>;
}
export interface ExternalInitialImportStatusDto {
imported: boolean;
sourceName: string | null;
objectCount: number;
importedAtIso: string | null;
fileName: string | null;
}
export interface ExternalInitialImportResultDto extends ProjectCommandResultDto {
import: {
sourceId: string;

View file

@ -39,6 +39,10 @@ import type {
CircuitTreeResponseDto,
ProjectCircuitTreesResponseDto,
ExternalModelConfigurationSnapshotDto,
ExternalInitialImportPlanDto,
ExternalInitialImportResultDto,
ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput,
CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto,
EstimateBalanceDto,
@ -198,6 +202,40 @@ export async function detectExternalModelWorkbook(
);
}
export function getExternalInitialImportStatus(projectId: string) {
return request<ExternalInitialImportStatusDto>(`/api/projects/${projectId}/external-model/import-status`);
}
export async function planExternalInitialImport(
projectId: string,
file: File,
configuration: ExternalModelConfiguration,
expectedRevision: number
) {
return request<ExternalInitialImportPlanDto>(`/api/projects/${projectId}/external-model/initial-import/plan`, {
method: "POST",
body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }),
});
}
export async function applyExternalInitialImport(
projectId: string,
file: File,
input: Omit<ApplyExternalInitialImportInput, "fileName" | "contentBase64">
) {
return request<ExternalInitialImportResultDto>(`/api/projects/${projectId}/external-model/initial-import/apply`, {
method: "POST",
body: JSON.stringify({ ...input, fileName: file.name, contentBase64: await fileToBase64(file) }),
});
}
async function fileToBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history`

View file

@ -64,6 +64,7 @@ const commandTypeLabels: Record<string, string> = {
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen",
"external-model.apply-initial-import": "Linear-XLSX-Erstimport übernommen",
"external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit":