"use client"; import React, { useEffect, useMemo, useState, type FormEvent } from "react"; import { createDefaultExternalCsvConfiguration, type ExternalCsvConfiguration, type ExternalCsvFamilyTypeRule, } from "../../external-model/csv/external-csv-contracts"; import type { CreateProjectDeviceInput, CreateRoomInput, ExternalCsvPreviewDto, ExternalInitialImportPlanDto, ProjectDeviceCommandResultDto, ProjectRoomCommandResultDto, } from "../types"; import { applyExternalInitialImport, getExternalCsvConfiguration, planExternalInitialImport, previewExternalCsv, updateExternalCsvConfiguration, } from "../utils/api"; import { FormModal } from "./form-modal"; import { ProjectDeviceModal } from "./project-device-modal"; import { RoomModal } from "./room-modal"; interface RevitCsvModalProps { currentRevision: number; onClose: () => void; onCreateProjectDevice: ( input: CreateProjectDeviceInput ) => Promise; onCreateRoom: (input: CreateRoomInput) => Promise; onRevisionChange: (currentRevision: number) => void; projectId: string; } const defaultConfiguration = createDefaultExternalCsvConfiguration({ ifcGuid: "IfcGUID", roomNumber: "MEP-Raum: Nummer", roomName: "MEP-Raum: Name", familyAndType: "Familie und Typ", selectionMarker: "CAx_Auswahlkenner", circuitIdentifier: "kbp_Stromkreisnummer", power: "kbp-E-Elektrische Leistung", quantity: null, }); defaultConfiguration.additionalSourceMappings = [ { sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" }, { sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" }, { sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" }, { sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" }, { sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" }, ]; const columnFields = [ ["ifcGuid", "IFC-GUID"], ["roomNumber", "Raumnummer"], ["roomName", "Raumname"], ["familyAndType", "Familie und Typ"], ["selectionMarker", "CAx-Auswahlkenner"], ["circuitIdentifier", "Stromkreiskennzeichnung"], ["power", "Elektrische Leistung"], ["quantity", "Menge (optional)"], ] as const; export function RevitCsvModal({ currentRevision, onClose, onCreateProjectDevice, onCreateRoom, onRevisionChange, projectId, }: RevitCsvModalProps) { const [configuration, setConfiguration] = useState( structuredClone(defaultConfiguration) ); const [savedConfiguration, setSavedConfiguration] = useState(null); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); const [importPlan, setImportPlan] = useState(null); const [roomDecisions, setRoomDecisions] = useState>({}); const [familyLinks, setFamilyLinks] = useState>({}); const [sourceName, setSourceName] = useState("Revit-Gesamtmodell"); const [success, setSuccess] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isPreviewing, setIsPreviewing] = useState(false); const [isPlanning, setIsPlanning] = useState(false); const [isApplying, setIsApplying] = useState(false); const [isCreatingCatalogEntry, setIsCreatingCatalogEntry] = useState(false); const [newRoomSourceKey, setNewRoomSourceKey] = useState(null); const [newProjectDeviceFamily, setNewProjectDeviceFamily] = useState(null); const [error, setError] = useState(null); useEffect(() => { getExternalCsvConfiguration(projectId) .then(({ configuration: stored }) => { const next = stored?.configuration ?? structuredClone(defaultConfiguration); setConfiguration(next); setSavedConfiguration(stored ? JSON.stringify(next) : null); }) .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht geladen werden.") ) .finally(() => setIsLoading(false)); }, [projectId]); const isDirty = savedConfiguration !== JSON.stringify(configuration); function replaceConfiguration(next: ExternalCsvConfiguration) { setConfiguration(next); setPreview(null); setImportPlan(null); setSuccess(null); } async function handleSave(event: FormEvent) { event.preventDefault(); setIsSaving(true); setError(null); try { const result = await updateExternalCsvConfiguration( projectId, currentRevision, configuration ); setSavedConfiguration(JSON.stringify(result.configuration.configuration)); setImportPlan(null); onRevisionChange(result.history.currentRevision); } catch (reason) { setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht gespeichert werden."); } finally { setIsSaving(false); } } async function handlePreview() { if (!file || isDirty || savedConfiguration === null) return; setIsPreviewing(true); setError(null); try { const contentBase64 = await fileToBase64(file); setPreview(await previewExternalCsv(projectId, file.name, contentBase64)); setImportPlan(null); setSuccess(null); } catch (reason) { setPreview(null); setError(reason instanceof Error ? reason.message : "CSV-Vorschau konnte nicht erstellt werden."); } finally { setIsPreviewing(false); } } async function handlePlanImport() { if (!file || !preview || isDirty || savedConfiguration === null) return; setIsPlanning(true); setError(null); try { const contentBase64 = await fileToBase64(file); const plan = await planExternalInitialImport(projectId, file.name, contentBase64); if (plan.sha256 !== preview.sha256) { throw new Error("Die Datei stimmt nicht mehr mit der Vorschau überein."); } setImportPlan(plan); setRoomDecisions(Object.fromEntries(plan.sourceRooms.map((room) => [ room.sourceRoomKey, { roomId: room.suggestedRoomId, defaultDistributionBoardId: null }, ]))); setFamilyLinks(Object.fromEntries(plan.familyGroups.map((group) => [ group.familyAndType, null, ]))); } catch (reason) { setImportPlan(null); setError(reason instanceof Error ? reason.message : "Erstimport konnte nicht geplant werden."); } finally { setIsPlanning(false); } } async function handleApplyImport() { if (!file || !importPlan) return; if (!window.confirm( `${importPlan.objectCount} Revit-Objekte jetzt als einen rückgängig machbaren Import übernehmen?` )) return; setIsApplying(true); setError(null); try { const contentBase64 = await fileToBase64(file); const result = await applyExternalInitialImport(projectId, { expectedRevision: currentRevision, expectedConfigurationVersion: importPlan.configurationVersion, expectedSha256: importPlan.sha256, fileName: file.name, contentBase64, sourceName, roomDecisions: importPlan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: roomDecisions[room.sourceRoomKey]?.roomId ?? null, defaultDistributionBoardId: roomDecisions[room.sourceRoomKey]?.defaultDistributionBoardId ?? null, })), familyProjectDeviceDecisions: importPlan.familyGroups.map((group) => ({ familyAndType: group.familyAndType, projectDeviceId: familyLinks[group.familyAndType] ?? null, })), }); onRevisionChange(result.history.currentRevision); setSuccess(`${result.import.objectCount} Revit-Objekte wurden übernommen.`); setImportPlan(null); setPreview(null); } catch (reason) { setError(reason instanceof Error ? reason.message : "Erstimport konnte nicht übernommen werden."); } finally { setIsApplying(false); } } function updateColumn(key: (typeof columnFields)[number][0], value: string) { setConfiguration((current) => ({ ...current, columns: { ...current.columns, [key]: key === "quantity" && !value.trim() ? null : value }, })); setPreview(null); setImportPlan(null); } async function handleCreateRoom(input: CreateRoomInput) { if (!newRoomSourceKey) return; setIsCreatingCatalogEntry(true); setError(null); try { const result = await onCreateRoom(input); setImportPlan((current) => current ? { ...current, existing: { ...current.existing, rooms: [...current.existing.rooms, result.room], }, } : current); setRoomDecisions((current) => ({ ...current, [newRoomSourceKey]: { roomId: result.room.id, defaultDistributionBoardId: current[newRoomSourceKey]?.defaultDistributionBoardId ?? null, }, })); setNewRoomSourceKey(null); } catch (reason) { setError(reason instanceof Error ? reason.message : "Raum konnte nicht angelegt werden."); setNewRoomSourceKey(null); } finally { setIsCreatingCatalogEntry(false); } } async function handleCreateProjectDevice(input: CreateProjectDeviceInput) { if (!newProjectDeviceFamily) return; setIsCreatingCatalogEntry(true); setError(null); try { const result = await onCreateProjectDevice(input); setImportPlan((current) => current ? { ...current, existing: { ...current.existing, projectDevices: [...current.existing.projectDevices, result.projectDevice], }, } : current); setFamilyLinks((current) => ({ ...current, [newProjectDeviceFamily]: result.projectDevice.id, })); setNewProjectDeviceFamily(null); } catch (reason) { setError(reason instanceof Error ? reason.message : "ProjectDevice konnte nicht angelegt werden."); setNewProjectDeviceFamily(null); } finally { setIsCreatingCatalogEntry(false); } } if (newRoomSourceKey && importPlan) { const sourceRoom = importPlan.sourceRooms.find((room) => room.sourceRoomKey === newRoomSourceKey); if (sourceRoom) { return ( ({ ...floor, projectId }))} initialValues={{ roomNumber: sourceRoom.roomNumber, roomName: sourceRoom.roomName }} isSaving={isCreatingCatalogEntry} onClose={() => setNewRoomSourceKey(null)} onSave={handleCreateRoom} /> ); } } if (newProjectDeviceFamily && importPlan) { const group = importPlan.familyGroups.find((entry) => entry.familyAndType === newProjectDeviceFamily); if (group) { return ( setNewProjectDeviceFamily(null)} onImportGlobal={async () => undefined} onSave={handleCreateProjectDevice} /> ); } } return ( {error ?
{error}
: null} {success ?
{success}
: null} {isLoading ?

Konfiguration wird geladen …

: (

Transport

replaceConfiguration({ ...configuration, wattsPerSourceUnit: Number(event.target.value) })} />

Spaltenzuordnung

{columnFields.map(([key, label]) => ( updateColumn(key, event.target.value)} /> ))}
replaceConfiguration({ ...configuration, additionalSourceMappings })} /> replaceConfiguration({ ...configuration, familyTypeRules })} />

CSV prüfen

Die Vorschau speichert keine Datei und verändert keine Projektdaten.

{ setFile(event.target.files?.[0] ?? null); setPreview(null); setImportPlan(null); setSuccess(null); }} style={{ maxWidth: 480 }} type="file" />
{isDirty ?
Vor der Vorschau muss die Konfiguration gespeichert werden.
: null}
{preview ? : null} {importPlan ? ( setFamilyLinks((current) => ({ ...current, [familyAndType]: projectDeviceId }))} onRoomDecisionChange={(sourceRoomKey, decision) => setRoomDecisions((current) => ({ ...current, [sourceRoomKey]: decision }))} plan={importPlan} roomDecisions={roomDecisions} setSourceName={setSourceName} sourceName={sourceName} /> ) : null}
)}
); } function Field({ children, label }: { children: React.ReactNode; label: string }) { return ; } function AdditionalMappings({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvConfiguration["additionalSourceMappings"]) => void }) { return

Weitere Quellfelder

{configuration.additionalSourceMappings.map((mapping, index) =>
onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, sourceColumn: event.target.value } : entry))} />
onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, targetField: event.target.value } : entry))} />
)}
; } function FamilyRules({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvFamilyTypeRule[]) => void }) { const rules = configuration.familyTypeRules; function change(index: number, patch: Partial) { onChange(rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, ...patch } : rule)); } return

Familie-und-Typ-Regeln

Exakter Textvergleich; Reihenfolge wird beibehalten.
{rules.map((rule, index) =>
change(index, { exactFamilyAndType: event.target.value })} /> change(index, { internalDeviceType: event.target.value })} /> change(index, { connectionKind: event.target.value || null })} />{rule.quantityRule.kind === "fixed" ? change(index, { quantityRule: { kind: "fixed", quantity: Number(event.target.value) } })} /> : null}{rule.displayNameSuggestion?.kind === "fixed" ? change(index, { displayNameSuggestion: { kind: "fixed", value: event.target.value } })} /> : null}
)}
; } function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) { const visibleObjects = useMemo(() => preview.objects.slice(0, 25), [preview]); return

Prüfergebnis

SHA-256: {preview.sha256}

{visibleObjects.map((object) => )}
ZeileIFC-GUIDRaumFamilie und TypAuswahlkennerStromkreis
{object.rowNumber}{object.ifcGuid}{[object.roomNumber, object.roomName].filter(Boolean).join(" · ") || "–"}{object.familyAndType || "–"}{object.selectionMarker || "–"}{object.circuitIdentifier || "–"}
{preview.objects.length > visibleObjects.length ?

Es werden die ersten {visibleObjects.length} von {preview.objects.length} Objekten angezeigt.

: null}
; } interface RoomDecision { roomId: string | null; defaultDistributionBoardId: string | null; } export function RevitCsvImportPlan({ familyLinks, isApplying, onApply, onCreateProjectDevice, onCreateRoom, onFamilyLinkChange, onRoomDecisionChange, plan, roomDecisions, setSourceName, sourceName, }: { familyLinks: Record; isApplying: boolean; onApply: () => void; onCreateProjectDevice: (familyAndType: string) => void; onCreateRoom: (sourceRoomKey: string) => void; onFamilyLinkChange: (familyAndType: string, projectDeviceId: string | null) => void; onRoomDecisionChange: (sourceRoomKey: string, decision: RoomDecision) => void; plan: ExternalInitialImportPlanDto; roomDecisions: Record; setSourceName: (value: string) => void; sourceName: string; }) { const hasBlockingIssues = plan.issueCounts.unknownFamilyAndType > 0; const warningCount = plan.issueCounts.invalidPower + plan.issueCounts.invalidQuantity + plan.issueCounts.missingRoom + plan.suspectObjectCount; const floorsById = new Map(plan.existing.floors.map((floor) => [floor.id, floor])); return (

Erstimport vorbereiten

Raum- und Gerätekatalog-Zuordnungen werden zusammen mit {plan.objectCount} Revit-Objekten gespeichert. Stromkreise und Gerätezeilen entstehen dabei noch nicht.

Konfiguration {plan.configurationVersion}
{hasBlockingIssues ? (
{plan.issueCounts.unknownFamilyAndType} Objekte besitzen noch keine exakte Familie-und-Typ-Regel. Ergänze die Regeln oben, speichere die Konfiguration und plane den Import erneut.
) : null} {plan.issueCounts.invalidPower > 0 ?
Bei {plan.issueCounts.invalidPower} Objekten ist die Leistung ungültig.
: null} {plan.issueCounts.invalidQuantity > 0 ?
Bei {plan.issueCounts.invalidQuantity} Objekten ist die Menge ungültig.
: null} {plan.issueCounts.missingRoom > 0 ?
{plan.issueCounts.missingRoom} Objekte besitzen keine Raumangabe.
: null} {plan.suspectObjectCount > 0 ?
{plan.suspectObjectCount} Objektzeilen wurden als verdächtig erkannt.
: null}

Räume zuordnen

{plan.sourceRooms.map((sourceRoom) => { const decision = roomDecisions[sourceRoom.sourceRoomKey] ?? { roomId: sourceRoom.suggestedRoomId, defaultDistributionBoardId: null, }; return ( ); })}
QuellraumObjekteTrefferInterner RaumStandardverteilung
{[sourceRoom.roomNumber, sourceRoom.roomName].filter(Boolean).join(" · ")} {sourceRoom.objectCount}

Familien und Typen zuordnen

{plan.familyGroups.map((group) => ( ))}
Familie und TypObjekteKlassifizierungProjectDevice (optional)
{group.familyAndType || "–"} {group.objectCount} {group.classified ? `${group.internalDeviceType ?? "–"} · ${categoryLabel(group.category)}` : Regel fehlt}
); } function RoomMatchBadge({ status }: { status: ExternalInitialImportPlanDto["sourceRooms"][number]["matchStatus"] }) { if (status === "exact-number") return Eindeutig; if (status === "ambiguous-number") return Mehrdeutig; return Neu; } function categoryLabel(category: string | null) { if (category === "lighting") return "Beleuchtung"; if (category === "single_phase") return "1-phasig"; if (category === "three_phase") return "3-phasig"; return "Ohne Kategorie"; } export function createRevitProjectDevicePrefill( plan: ExternalInitialImportPlanDto, familyAndType: string ): Partial { const group = plan.familyGroups.find((entry) => entry.familyAndType === familyAndType); const firstObject = plan.objects.find( (object) => object.sourceValues.familyAndType === familyAndType ); const category = group?.category; return { name: group?.internalDeviceType || familyAndType || "Revit-Gerät", displayName: firstObject?.suggestedPlanningValues.displayName || familyAndType, connectionKind: group?.connectionKind ?? undefined, category: category === "lighting" || category === "three_phase" ? category : "single_phase", quantity: firstObject?.suggestedPlanningValues.effectiveQuantity ?? 1, powerPerUnit: firstObject?.suggestedPlanningValues.powerPerUnitW === null || firstObject?.suggestedPlanningValues.powerPerUnitW === undefined ? 0.1 : firstObject.suggestedPlanningValues.powerPerUnitW / 1000, simultaneityFactor: firstObject?.suggestedPlanningValues.simultaneityFactor ?? 1, cosPhi: firstObject?.suggestedPlanningValues.cosPhi ?? undefined, costGroup: firstObject?.suggestedPlanningValues.costGroup ?? undefined, remark: firstObject?.suggestedPlanningValues.remark ?? undefined, }; } function Stat({ label, value }: { label: string; value: number }) { return
{label}
{value}
; } function fileToBase64(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error("CSV-Datei konnte nicht gelesen werden.")); reader.onload = () => { const result = String(reader.result ?? ""); const commaIndex = result.indexOf(","); if (commaIndex < 0) reject(new Error("CSV-Datei konnte nicht kodiert werden.")); else resolve(result.slice(commaIndex + 1)); }; reader.readAsDataURL(file); }); }