leistungsbilanz-ts/src/frontend/components/revit-csv-modal.tsx

666 lines
34 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 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<ProjectDeviceCommandResultDto>;
onCreateRoom: (input: CreateRoomInput) => Promise<ProjectRoomCommandResultDto>;
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<ExternalCsvConfiguration>(
structuredClone(defaultConfiguration)
);
const [savedConfiguration, setSavedConfiguration] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ExternalCsvPreviewDto | null>(null);
const [importPlan, setImportPlan] = useState<ExternalInitialImportPlanDto | null>(null);
const [roomDecisions, setRoomDecisions] = useState<Record<string, { roomId: string | null; defaultDistributionBoardId: string | null }>>({});
const [familyLinks, setFamilyLinks] = useState<Record<string, string | null>>({});
const [sourceName, setSourceName] = useState("Revit-Gesamtmodell");
const [success, setSuccess] = useState<string | null>(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<string | null>(null);
const [newProjectDeviceFamily, setNewProjectDeviceFamily] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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 (
<RoomModal
floors={importPlan.existing.floors.map((floor) => ({ ...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 (
<ProjectDeviceModal
globalDevices={[]}
initialValues={createRevitProjectDevicePrefill(importPlan, group.familyAndType)}
isSaving={isCreatingCatalogEntry}
onClose={() => setNewProjectDeviceFamily(null)}
onImportGlobal={async () => undefined}
onSave={handleCreateProjectDevice}
/>
);
}
}
return (
<FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
dialogSize="viewport"
isSaving={isSaving || isApplying}
onClose={onClose}
onSubmit={handleSave}
submitDisabled={isLoading || isPreviewing || isPlanning || isApplying || !isDirty}
submitLabel="Konfiguration speichern"
title="Revit-CSV"
>
{error ? <div className="alert alert-warning">{error}</div> : null}
{success ? <div className="alert alert-success">{success}</div> : null}
{isLoading ? <p>Konfiguration wird geladen …</p> : (
<div className="d-grid gap-4">
<section>
<h3 className="h6">Transport</h3>
<div className="row g-3">
<Field label="Encoding"><select className="form-select" disabled value={configuration.encoding}><option value="utf-8">UTF-8</option></select></Field>
<Field label="Trennzeichen"><select className="form-select" value={configuration.delimiter} onChange={(event) => replaceConfiguration({ ...configuration, delimiter: event.target.value as ExternalCsvConfiguration["delimiter"] })}><option value=";">Semikolon (;)</option><option value=",">Komma (,)</option><option value="\t">Tabulator</option></select></Field>
<Field label="Dezimaltrennzeichen"><select className="form-select" value={configuration.decimalSeparator} onChange={(event) => replaceConfiguration({ ...configuration, decimalSeparator: event.target.value as "," | "." })}><option value=",">Komma</option><option value=".">Punkt</option></select></Field>
<Field label="Leistungseinheit"><select className="form-select" value={configuration.powerUnit} onChange={(event) => replaceConfiguration({ ...configuration, powerUnit: event.target.value as "W" | "kW", wattsPerSourceUnit: event.target.value === "kW" ? 1000 : 1 })}><option value="W">Watt</option><option value="kW">Kilowatt</option></select></Field>
<Field label="Umrechnung in Watt"><input className="form-control" min="0.000001" step="any" type="number" value={configuration.wattsPerSourceUnit} onChange={(event) => replaceConfiguration({ ...configuration, wattsPerSourceUnit: Number(event.target.value) })} /></Field>
</div>
</section>
<section>
<h3 className="h6">Spaltenzuordnung</h3>
<div className="row g-3">
{columnFields.map(([key, label]) => (
<Field key={key} label={label}>
<input className="form-control" value={configuration.columns[key] ?? ""} onChange={(event) => updateColumn(key, event.target.value)} />
</Field>
))}
</div>
</section>
<AdditionalMappings configuration={configuration} onChange={(additionalSourceMappings) => replaceConfiguration({ ...configuration, additionalSourceMappings })} />
<FamilyRules configuration={configuration} onChange={(familyTypeRules) => replaceConfiguration({ ...configuration, familyTypeRules })} />
<section className="border-top pt-3">
<h3 className="h6">CSV prüfen</h3>
<p className="small text-secondary">Die Vorschau speichert keine Datei und verändert keine Projektdaten.</p>
<div className="d-flex flex-wrap gap-2 align-items-center">
<input accept=".csv,text/csv" className="form-control" onChange={(event) => { setFile(event.target.files?.[0] ?? null); setPreview(null); setImportPlan(null); setSuccess(null); }} style={{ maxWidth: 480 }} type="file" />
<button className="btn btn-outline-primary" disabled={!file || isDirty || savedConfiguration === null || isPreviewing || isSaving} onClick={handlePreview} type="button">{isPreviewing ? "Wird geprüft …" : "Vorschau erstellen"}</button>
<button className="btn btn-primary" disabled={!preview || isDirty || isPlanning || isPreviewing || isSaving} onClick={handlePlanImport} type="button">{isPlanning ? "Wird geplant …" : "Erstimport planen"}</button>
</div>
{isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null}
</section>
{preview ? <PreviewResult preview={preview} /> : null}
{importPlan ? (
<RevitCsvImportPlan
familyLinks={familyLinks}
isApplying={isApplying}
onApply={handleApplyImport}
onCreateProjectDevice={setNewProjectDeviceFamily}
onCreateRoom={setNewRoomSourceKey}
onFamilyLinkChange={(familyAndType, projectDeviceId) => setFamilyLinks((current) => ({ ...current, [familyAndType]: projectDeviceId }))}
onRoomDecisionChange={(sourceRoomKey, decision) => setRoomDecisions((current) => ({ ...current, [sourceRoomKey]: decision }))}
plan={importPlan}
roomDecisions={roomDecisions}
setSourceName={setSourceName}
sourceName={sourceName}
/>
) : null}
</div>
)}
</FormModal>
);
}
function Field({ children, label }: { children: React.ReactNode; label: string }) {
return <label className="col-12 col-md-6"><span className="form-label">{label}</span>{children}</label>;
}
function AdditionalMappings({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvConfiguration["additionalSourceMappings"]) => void }) {
return <section><div className="d-flex justify-content-between align-items-center"><h3 className="h6 mb-0">Weitere Quellfelder</h3><button className="btn btn-sm btn-outline-secondary" onClick={() => onChange([...configuration.additionalSourceMappings, { sourceColumn: "", targetField: "" }])} type="button">Zuordnung hinzufügen</button></div><div className="d-grid gap-2 mt-2">{configuration.additionalSourceMappings.map((mapping, index) => <div className="row g-2" key={index}><div className="col"><input aria-label={`Quellspalte ${index + 1}`} className="form-control" placeholder="CSV-Spalte" value={mapping.sourceColumn} onChange={(event) => onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, sourceColumn: event.target.value } : entry))} /></div><div className="col"><input aria-label={`Zielfeld ${index + 1}`} className="form-control" placeholder="Internes Zielfeld" value={mapping.targetField} onChange={(event) => onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, targetField: event.target.value } : entry))} /></div><div className="col-auto"><button aria-label={`Zuordnung ${index + 1} entfernen`} className="btn btn-outline-danger" onClick={() => onChange(configuration.additionalSourceMappings.filter((_, entryIndex) => entryIndex !== index))} type="button">Entfernen</button></div></div>)}</div></section>;
}
function FamilyRules({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvFamilyTypeRule[]) => void }) {
const rules = configuration.familyTypeRules;
function change(index: number, patch: Partial<ExternalCsvFamilyTypeRule>) { onChange(rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, ...patch } : rule)); }
return <section><div className="d-flex justify-content-between align-items-center"><div><h3 className="h6 mb-0">Familie-und-Typ-Regeln</h3><div className="form-text">Exakter Textvergleich; Reihenfolge wird beibehalten.</div></div><button className="btn btn-sm btn-outline-secondary" onClick={() => onChange([...rules, { exactFamilyAndType: "", internalDeviceType: "", connectionKind: null, category: "single_phase", quantityRule: { kind: "fixed", quantity: 1 }, displayNameSuggestion: null }])} type="button">Regel hinzufügen</button></div><div className="d-grid gap-3 mt-2">{rules.map((rule, index) => <div className="border rounded p-3" key={index}><div className="row g-2"><Field label="Exakter Familie-und-Typ-Wert"><input className="form-control" value={rule.exactFamilyAndType} onChange={(event) => change(index, { exactFamilyAndType: event.target.value })} /></Field><Field label="Interner Gerätetyp"><input className="form-control" value={rule.internalDeviceType} onChange={(event) => change(index, { internalDeviceType: event.target.value })} /></Field><Field label="Anschlussart (optional)"><input className="form-control" value={rule.connectionKind ?? ""} onChange={(event) => change(index, { connectionKind: event.target.value || null })} /></Field><Field label="Kategorie"><select className="form-select" value={rule.category} onChange={(event) => change(index, { category: event.target.value as ExternalCsvFamilyTypeRule["category"] })}><option value="lighting">Beleuchtung</option><option value="single_phase">1-phasig</option><option value="three_phase">3-phasig</option></select></Field><Field label="Mengenregel"><select className="form-select" value={rule.quantityRule.kind} onChange={(event) => change(index, { quantityRule: event.target.value === "mapped-column" ? { kind: "mapped-column" } : { kind: "fixed", quantity: 1 } })}><option value="fixed">Feste Menge</option><option disabled={configuration.columns.quantity === null} value="mapped-column">Aus Mengenspalte</option></select></Field>{rule.quantityRule.kind === "fixed" ? <Field label="Feste Menge"><input className="form-control" min="0.001" step="any" type="number" value={rule.quantityRule.quantity} onChange={(event) => change(index, { quantityRule: { kind: "fixed", quantity: Number(event.target.value) } })} /></Field> : null}<Field label="Anzeigename vorschlagen aus"><select className="form-select" value={rule.displayNameSuggestion?.kind ?? "none"} onChange={(event) => change(index, { displayNameSuggestion: event.target.value === "none" ? null : event.target.value === "fixed" ? { kind: "fixed", value: "" } : { kind: event.target.value as "selection-marker" | "family-and-type" } })}><option value="none">Kein Vorschlag</option><option value="selection-marker">CAx-Auswahlkenner</option><option value="family-and-type">Familie und Typ</option><option value="fixed">Fester Text</option></select></Field>{rule.displayNameSuggestion?.kind === "fixed" ? <Field label="Fester Anzeigename"><input className="form-control" value={rule.displayNameSuggestion.value} onChange={(event) => change(index, { displayNameSuggestion: { kind: "fixed", value: event.target.value } })} /></Field> : null}</div><button className="btn btn-sm btn-outline-danger mt-2" onClick={() => onChange(rules.filter((_, ruleIndex) => ruleIndex !== index))} type="button">Regel entfernen</button></div>)}</div></section>;
}
function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) {
const visibleObjects = useMemo(() => preview.objects.slice(0, 25), [preview]);
return <section className="border-top pt-3"><h3 className="h6">Prüfergebnis</h3><div className="row g-2 mb-3"><Stat label="Objekte" value={preview.objectCount} /><Stat label="Passthrough-Zeilen" value={preview.passthroughCount} /><Stat label="Verdachtszeilen" value={preview.suspectObjectCount} /><Stat label="Kopfzeile" value={preview.headerRowNumber} /></div><p className="small text-secondary text-break mb-2">SHA-256: {preview.sha256}</p><div className="table-responsive"><table className="table table-sm"><thead><tr><th>Zeile</th><th>IFC-GUID</th><th>Raum</th><th>Familie und Typ</th><th>Auswahlkenner</th><th>Stromkreis</th></tr></thead><tbody>{visibleObjects.map((object) => <tr key={object.ifcGuid}><td>{object.rowNumber}</td><td><code>{object.ifcGuid}</code></td><td>{[object.roomNumber, object.roomName].filter(Boolean).join(" · ") || "–"}</td><td>{object.familyAndType || "–"}</td><td>{object.selectionMarker || "–"}</td><td>{object.circuitIdentifier || "–"}</td></tr>)}</tbody></table></div>{preview.objects.length > visibleObjects.length ? <p className="small text-secondary">Es werden die ersten {visibleObjects.length} von {preview.objects.length} Objekten angezeigt.</p> : null}</section>;
}
interface RoomDecision {
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export function RevitCsvImportPlan({
familyLinks,
isApplying,
onApply,
onCreateProjectDevice,
onCreateRoom,
onFamilyLinkChange,
onRoomDecisionChange,
plan,
roomDecisions,
setSourceName,
sourceName,
}: {
familyLinks: Record<string, string | null>;
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<string, RoomDecision>;
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 (
<section className="border-top pt-3">
<div className="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div>
<h3 className="h6 mb-1">Erstimport vorbereiten</h3>
<p className="small text-secondary mb-0">
Raum- und Gerätekatalog-Zuordnungen werden zusammen mit {plan.objectCount} Revit-Objekten gespeichert.
Stromkreise und Gerätezeilen entstehen dabei noch nicht.
</p>
</div>
<span className="badge text-bg-secondary">Konfiguration {plan.configurationVersion}</span>
</div>
<div className="row g-2 my-2">
<Stat label="Quellräume" value={plan.sourceRooms.length} />
<Stat label="Familien/Typen" value={plan.familyGroups.length} />
<Stat label="Objekte" value={plan.objectCount} />
<Stat label="Hinweise" value={warningCount} />
</div>
{hasBlockingIssues ? (
<div className="alert alert-danger">
{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.
</div>
) : null}
{plan.issueCounts.invalidPower > 0 ? <div className="alert alert-warning py-2">Bei {plan.issueCounts.invalidPower} Objekten ist die Leistung ungültig.</div> : null}
{plan.issueCounts.invalidQuantity > 0 ? <div className="alert alert-warning py-2">Bei {plan.issueCounts.invalidQuantity} Objekten ist die Menge ungültig.</div> : null}
{plan.issueCounts.missingRoom > 0 ? <div className="alert alert-warning py-2">{plan.issueCounts.missingRoom} Objekte besitzen keine Raumangabe.</div> : null}
{plan.suspectObjectCount > 0 ? <div className="alert alert-warning py-2">{plan.suspectObjectCount} Objektzeilen wurden als verdächtig erkannt.</div> : null}
<label className="form-label mt-2" style={{ maxWidth: 520 }}>
<span>Bezeichnung des externen Modells</span>
<input
className="form-control"
onChange={(event) => setSourceName(event.target.value)}
value={sourceName}
/>
</label>
<h4 className="h6 mt-4">Räume zuordnen</h4>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead><tr><th>Quellraum</th><th>Objekte</th><th>Treffer</th><th>Interner Raum</th><th>Standardverteilung</th></tr></thead>
<tbody>
{plan.sourceRooms.map((sourceRoom) => {
const decision = roomDecisions[sourceRoom.sourceRoomKey] ?? {
roomId: sourceRoom.suggestedRoomId,
defaultDistributionBoardId: null,
};
return (
<tr key={sourceRoom.sourceRoomKey}>
<td>{[sourceRoom.roomNumber, sourceRoom.roomName].filter(Boolean).join(" · ")}</td>
<td>{sourceRoom.objectCount}</td>
<td><RoomMatchBadge status={sourceRoom.matchStatus} /></td>
<td>
<div className="input-group input-group-sm">
<select
aria-label={`Interner Raum für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select"
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
...decision,
roomId: event.target.value || null,
})}
value={decision.roomId ?? ""}
>
<option value="">Nicht verknüpfen</option>
{plan.existing.rooms.map((room) => {
const floor = room.floorId ? floorsById.get(room.floorId) : null;
return <option key={room.id} value={room.id}>{room.roomNumber} · {room.roomName}{floor ? ` (${floor.name})` : ""}</option>;
})}
</select>
<button
aria-label={`Raum ${sourceRoom.roomNumber || sourceRoom.roomName} neu anlegen`}
className="btn btn-outline-secondary"
onClick={() => onCreateRoom(sourceRoom.sourceRoomKey)}
type="button"
>
Neu
</button>
</div>
</td>
<td>
<select
aria-label={`Standardverteilung für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select form-select-sm"
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
...decision,
defaultDistributionBoardId: event.target.value || null,
})}
value={decision.defaultDistributionBoardId ?? ""}
>
<option value="">Keine Standardverteilung</option>
{plan.existing.distributionBoards.map((board) => <option key={board.id} value={board.id}>{board.name}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<h4 className="h6 mt-4">Familien und Typen zuordnen</h4>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead><tr><th>Familie und Typ</th><th>Objekte</th><th>Klassifizierung</th><th>ProjectDevice (optional)</th></tr></thead>
<tbody>
{plan.familyGroups.map((group) => (
<tr key={group.familyAndType}>
<td>{group.familyAndType || "–"}</td>
<td>{group.objectCount}</td>
<td>
{group.classified
? `${group.internalDeviceType ?? "–"} · ${categoryLabel(group.category)}`
: <span className="badge text-bg-danger">Regel fehlt</span>}
</td>
<td>
<div className="input-group input-group-sm">
<select
aria-label={`ProjectDevice für ${group.familyAndType}`}
className="form-select"
onChange={(event) => onFamilyLinkChange(group.familyAndType, event.target.value || null)}
value={familyLinks[group.familyAndType] ?? ""}
>
<option value="">Nicht verknüpfen</option>
{plan.existing.projectDevices.map((device) => (
<option key={device.id} value={device.id}>{device.displayName || device.name} · {categoryLabel(device.category)}</option>
))}
</select>
<button
aria-label={`ProjectDevice für ${group.familyAndType} neu anlegen`}
className="btn btn-outline-secondary"
disabled={!group.classified}
onClick={() => onCreateProjectDevice(group.familyAndType)}
type="button"
>
Neu
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="d-flex justify-content-end mt-3">
<button
className="btn btn-primary"
disabled={hasBlockingIssues || isApplying || !sourceName.trim()}
onClick={onApply}
type="button"
>
{isApplying ? "Import wird übernommen …" : "Erstimport übernehmen"}
</button>
</div>
</section>
);
}
function RoomMatchBadge({ status }: { status: ExternalInitialImportPlanDto["sourceRooms"][number]["matchStatus"] }) {
if (status === "exact-number") return <span className="badge text-bg-success">Eindeutig</span>;
if (status === "ambiguous-number") return <span className="badge text-bg-warning">Mehrdeutig</span>;
return <span className="badge text-bg-secondary">Neu</span>;
}
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<CreateProjectDeviceInput> {
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 <div className="col-6 col-lg-3"><div className="border rounded p-2"><div className="small text-secondary">{label}</div><strong>{value}</strong></div></div>; }
function fileToBase64(file: File) {
return new Promise<string>((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);
});
}