Add Revit CSV project dialog

This commit is contained in:
Julian Appel 2026-08-02 16:59:18 +02:00
parent caa6caf99f
commit a9d6301753
8 changed files with 386 additions and 4 deletions

View file

@ -59,6 +59,7 @@ import {
} from "../../../frontend/components/project-settings-modal";
import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@ -105,6 +106,7 @@ export default function ProjectDetailPage() {
const [roomFloorId, setRoomFloorId] = useState("");
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState<
"board" | "floor" | "room" | null
>(null);
@ -858,6 +860,14 @@ export default function ProjectDetailPage() {
</p>
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary"
disabled={!project}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
>
Revit-CSV
</button>
<button
className="btn btn-outline-primary"
disabled={!project}
@ -1678,6 +1688,14 @@ export default function ProjectDetailPage() {
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/>
) : null}
{project && isRevitCsvOpen ? (
<RevitCsvModal
currentRevision={project.currentRevision}
onClose={() => setIsRevitCsvOpen(false)}
onRevisionChange={applyProjectRevision}
projectId={projectId}
/>
) : null}
</main>
);
}

View file

@ -0,0 +1,220 @@
"use client";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
createDefaultExternalCsvConfiguration,
type ExternalCsvConfiguration,
type ExternalCsvFamilyTypeRule,
} from "../../external-model/csv/external-csv-contracts";
import type { ExternalCsvPreviewDto } from "../types";
import {
getExternalCsvConfiguration,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../utils/api";
import { FormModal } from "./form-modal";
interface RevitCsvModalProps {
currentRevision: number;
onClose: () => void;
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,
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 [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
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);
}
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));
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));
} catch (reason) {
setPreview(null);
setError(reason instanceof Error ? reason.message : "CSV-Vorschau konnte nicht erstellt werden.");
} finally {
setIsPreviewing(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);
}
return (
<FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSave}
submitDisabled={isLoading || isPreviewing || !isDirty}
submitLabel="Konfiguration speichern"
title="Revit-CSV"
>
{error ? <div className="alert alert-warning">{error}</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); }} 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>
</div>
{isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null}
</section>
{preview ? <PreviewResult preview={preview} /> : 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>;
}
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);
});
}

View file

@ -10,6 +10,46 @@ import type {
ProtectionDeviceType,
RcdType,
} from "../shared/constants/protection-device";
import type {
ExternalCsvConfiguration,
ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts";
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 ProjectDto {
id: string;

View file

@ -26,7 +26,10 @@ import type {
ProjectDto,
RoomDto,
CircuitTreeResponseDto,
ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto,
} from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import {
createCircuitUpdateProjectCommand,
@ -334,6 +337,39 @@ export function updateDistributionBoard(
);
}
export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration`
);
}
export function updateExternalCsvConfiguration(
projectId: string,
expectedRevision: number,
configuration: ExternalCsvConfiguration
) {
return request<
ProjectCommandResultDto & { configuration: ExternalCsvConfigurationSnapshotDto }
>(`/api/projects/${projectId}/external-csv/configuration`, {
method: "PUT",
body: JSON.stringify({ expectedRevision, configuration }),
});
}
export function previewExternalCsv(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<ExternalCsvPreviewDto>(
`/api/projects/${projectId}/external-csv/preview`,
{
method: "POST",
body: JSON.stringify({ fileName, contentBase64 }),
}
);
}
export function copyDistributionBoard(
projectId: string,
distributionBoardId: string,