Add Revit XLSX settings page

This commit is contained in:
Julian Appel 2026-09-30 09:02:52 +02:00
parent 1cfeb06ec7
commit c633e78cd4
15 changed files with 259 additions and 1025 deletions

View file

@ -329,8 +329,8 @@ the old development-only external CSV data after resetting linked row totals to
their manual share. Snapshot schema 10, restore and project transfer use the their manual share. Snapshot schema 10, restore and project transfer use the
new state; non-empty legacy CSV snapshots and legacy CSV history entries are new state; non-empty legacy CSV snapshots and legacy CSV history entries are
rejected with a German error. The backend CSV parser and routes are removed. rejected with a German error. The backend CSV parser and routes are removed.
The old project-page frontend remains only until its replacement in R4; do not R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
reconnect it to runtime CSV endpoints. See server-side sample detection, and removes the old project-page CSV modal. See
`docs/spec/revit-excel-integration-requirements.md` and `docs/spec/revit-excel-integration-requirements.md` and
`docs/spec/revit-excel-phase-14-plan.md`. `docs/spec/revit-excel-phase-14-plan.md`.
@ -586,7 +586,7 @@ Critical target rules:
selected combined/separate mode block export. selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation. - Missing GZF defaults visibly to 1 without separate confirmation.
- The corrected R2 return workbook was successfully imported into Linear on - The corrected R2 return workbook was successfully imported into Linear on
2026-09-30; R3 is complete and R4 is the next package. 2026-09-30; R3 and R4 are complete and R5 is the next package.
- All confirmed writes use typed project commands with `expectedRevision`, - All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary. complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source - Do not add automatic circuit planning, Revit circuit adoption, multi-source

View file

@ -11,9 +11,9 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R3 sind abgeschlossen; Worksharing-ID als externem Schlüssel ersetzt. R0 bis R4 sind abgeschlossen;
die eigene Revit-Seite, Erstimport, Editorzuordnung, Rückgabe und Folgeimport die eigene Revit-Seite mit XLSX-Einstellungen ist vorhanden. Erstimport,
folgen in R4 bis R10. Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10.
## Unterstützter Arbeitsablauf ## Unterstützter Arbeitsablauf

View file

@ -12,7 +12,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. | | R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. | | R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. | | R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. | | R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | | R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | | R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | | R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |

View file

@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
FileUp, FileSpreadsheet,
Library, Library,
Link2, Link2,
ListTree, ListTree,
@ -51,7 +51,6 @@ import {
copyProjectDeviceToGlobal, copyProjectDeviceToGlobal,
createDistributionBoard, createDistributionBoard,
createProjectDevice, createProjectDevice,
createRoom,
deleteProjectDevice, deleteProjectDevice,
deleteDistributionBoard, deleteDistributionBoard,
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
@ -83,7 +82,6 @@ import type {
BuildingDto, BuildingDto,
CircuitListDto, CircuitListDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CreateRoomInput,
CreateProjectDeviceInput, CreateProjectDeviceInput,
DistributionBoardDto, DistributionBoardDto,
FloorDto, FloorDto,
@ -115,7 +113,6 @@ import {
} from "../../../frontend/components/project-settings-modal"; } from "../../../frontend/components/project-settings-modal";
import { FormModal } from "../../../frontend/components/form-modal"; import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options"; import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = { const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
@ -200,7 +197,6 @@ export default function ProjectDetailPage() {
}) })
); );
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState<"board" | null>(null); const [structureModal, setStructureModal] = useState<"board" | null>(null);
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
useState(false); useState(false);
@ -679,22 +675,6 @@ export default function ProjectDetailPage() {
} }
} }
async function handleCreateRevitRoom(input: CreateRoomInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createRoom(projectId, input, project.currentRevision);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createProjectDevice(projectId, input, project.currentRevision);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCopyBoard() { async function handleCopyBoard() {
if ( if (
!projectId || !projectId ||
@ -997,15 +977,13 @@ export default function ProjectDetailPage() {
<Printer aria-hidden="true" size={16} /> <Printer aria-hidden="true" size={16} />
Seitenansicht Seitenansicht
</Link> </Link>
<button <Link
className="btn btn-outline-primary btn-with-icon" className="btn btn-outline-primary btn-with-icon"
disabled={!project} href={`/projects/${projectId}/revit`}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
> >
<FileUp aria-hidden="true" size={16} strokeWidth={2} /> <FileSpreadsheet aria-hidden="true" size={16} strokeWidth={2} />
Revit-CSV Revit
</button> </Link>
<button <button
className="btn btn-outline-primary btn-with-icon" className="btn btn-outline-primary btn-with-icon"
disabled={!project} disabled={!project}
@ -1897,16 +1875,6 @@ export default function ProjectDetailPage() {
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes} usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/> />
) : null} ) : null}
{project && isRevitCsvOpen ? (
<RevitCsvModal
currentRevision={project.currentRevision}
onClose={() => setIsRevitCsvOpen(false)}
onCreateProjectDevice={handleCreateRevitProjectDevice}
onCreateRoom={handleCreateRevitRoom}
onRevisionChange={applyProjectRevision}
projectId={projectId}
/>
) : null}
</main> </main>
); );
} }

View file

@ -0,0 +1,34 @@
"use client";
import { ArrowLeft, FileSpreadsheet, Settings } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
import { detectExternalModelWorkbook, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
import type { ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
export default function RevitPage() {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<ProjectDto | null>(null);
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { Promise.all([getProject(projectId), getExternalModelConfiguration(projectId)]).then(([loadedProject, result]) => { setProject(loadedProject); setSnapshot(result.configuration); }).catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden.")); }, [projectId]);
async function save(configuration: ExternalModelConfiguration) {
if (!project) return;
setSaving(true); setError(null);
try { const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration); setSnapshot(result.configuration); setProject({ ...project, currentRevision: result.history.currentRevision }); setOpen(false); }
catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
finally { setSaving(false); }
}
return <main className="container py-4">
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2><p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden. Der Erstimport folgt in R5.</p></div></section></div></div>
{open ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isSaving={saving} onClose={() => setOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
</main>;
}

View file

@ -1,666 +0,0 @@
"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);
});
}

View file

@ -0,0 +1,45 @@
"use client";
import { useState, type FormEvent } from "react";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ExternalXlsxColumnKey } from "../../external-model/xlsx/external-xlsx-contracts";
import { FormModal } from "./form-modal";
const labels: Record<ExternalXlsxColumnKey, string> = {
worksharingId: "Worksharing-ID", ifcGuid: "IfcGUID", roomNumber: "Raumnummer",
roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner",
socketCount: "Anzahl Objekte", sourceRemark: "Quellbemerkung", circuitCombined: "Stromkreis kombiniert",
boardName: "Verteiler", circuitIdentifier: "Stromkreis", designation: "Bezeichnung",
electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke",
};
export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onInspectSample, onSave }: {
configuration: ExternalModelConfiguration;
isSaving: boolean;
onClose: () => void;
onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise<string>;
onSave: (configuration: ExternalModelConfiguration) => Promise<void>;
}) {
const [draft, setDraft] = useState(() => structuredClone(configuration));
const [sampleStatus, setSampleStatus] = useState<string | null>(null);
const columnKeys = Object.keys(draft.columns) as ExternalXlsxColumnKey[];
async function inspectSample(file: File | undefined) {
if (!file) return;
try {
setSampleStatus(await onInspectSample(file, draft));
} catch (error) {
setSampleStatus(error instanceof Error ? error.message : "Musterdatei konnte nicht gelesen werden.");
}
}
return <FormModal title="Revit-XLSX-Einstellungen" description="Layout und Spalten der Linear-Bauteilliste" isSaving={isSaving} onClose={onClose} submitDisabled={JSON.stringify(draft) === JSON.stringify(configuration)} submitLabel="Einstellungen speichern" onSubmit={(event: FormEvent) => { event.preventDefault(); void onSave(draft); }}>
<div className="row g-3">
<div className="col-12"><label className="form-label" htmlFor="revit-sample">Musterdatei prüfen</label><input id="revit-sample" className="form-control" type="file" accept=".xlsx" onChange={(event) => void inspectSample(event.target.files?.[0])} />{sampleStatus ? <div className="form-text">{sampleStatus}</div> : null}</div>
{(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) => <div className="col-md-4" key={key}><label className="form-label" htmlFor={`layout-${key}`}>{key === "headerRow" ? "Kopfzeile" : key === "firstDataRow" ? "Erste Datenzeile" : "Erste Datenspalte"}</label><input id={`layout-${key}`} className="form-control" min={1} type="number" value={draft.layout[key]} onChange={(event) => setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} /></div>)}
<div className="col-12"><label className="form-label" htmlFor="circuit-mode">Stromkreisspalten</label><select id="circuit-mode" className="form-select" value={draft.circuitMode} onChange={(event) => setDraft({ ...draft, circuitMode: event.target.value as ExternalModelConfiguration["circuitMode"] })}><option value="combined">Kombinierte Stromkreisspalte</option><option value="separate">Verteiler und Stromkreis getrennt</option></select></div>
<div className="col-12"><h3 className="fs-6 mb-0">Spaltenzuordnung</h3></div>
{columnKeys.map((key) => <div className="col-md-6" key={key}><label className="form-label" htmlFor={`column-${key}`}>{labels[key]}</label><input id={`column-${key}`} className="form-control" value={draft.columns[key] ?? ""} onChange={(event) => setDraft({ ...draft, columns: { ...draft.columns, [key]: event.target.value.trim() ? event.target.value : null } })} /></div>)}
</div>
</FormModal>;
}

View file

@ -14,6 +14,13 @@ import type {
ExternalCsvConfiguration, ExternalCsvConfiguration,
ExternalCsvDialect, ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts"; } from "../external-model/csv/external-csv-contracts";
import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration";
export interface ExternalModelConfigurationSnapshotDto {
id: string;
projectId: string;
configuration: ExternalModelConfiguration;
}
export interface ExternalCsvConfigurationSnapshotDto { export interface ExternalCsvConfigurationSnapshotDto {
id: string; id: string;

View file

@ -38,18 +38,14 @@ import type {
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
ProjectCircuitTreesResponseDto, ProjectCircuitTreesResponseDto,
ExternalCsvConfigurationSnapshotDto, ExternalModelConfigurationSnapshotDto,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
ApplyExternalInitialImportInput,
ExternalInitialImportResultDto,
CreateEstimateBalanceInput, CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto, EstimateBalanceCommandResultDto,
EstimateBalanceDto, EstimateBalanceDto,
EstimateBalanceUpdateResultDto, EstimateBalanceUpdateResultDto,
EstimateComparisonDto, EstimateComparisonDto,
} from "../types"; } from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import { import {
createCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand,
@ -168,6 +164,40 @@ export function getProject(projectId: string) {
return request<ProjectDto>(`/api/projects/${projectId}`); return request<ProjectDto>(`/api/projects/${projectId}`);
} }
export function getExternalModelConfiguration(projectId: string) {
return request<{ configuration: ExternalModelConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-model/configuration`
);
}
export function updateExternalModelConfiguration(
projectId: string,
expectedRevision: number,
configuration: ExternalModelConfiguration
) {
return request<{
configuration: ExternalModelConfigurationSnapshotDto;
history: ProjectHistoryStateDto;
}>(`/api/projects/${projectId}/external-model/configuration`, {
method: "PUT",
body: JSON.stringify({ expectedRevision, configuration }),
});
}
export async function detectExternalModelWorkbook(
projectId: string,
file: File,
configuration: ExternalModelConfiguration
) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return request<{ sheetName: string; columnCount: number; objectCount: number }>(
`/api/projects/${projectId}/external-model/configuration/detect`,
{ method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: btoa(binary), configuration }) }
);
}
export function getProjectHistory(projectId: string) { export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>( return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history` `/api/projects/${projectId}/history`
@ -469,12 +499,6 @@ export function deleteDocumentLogo() {
return request<void>(documentLogoUrl, { method: "DELETE" }); return request<void>(documentLogoUrl, { method: "DELETE" });
} }
export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration`
);
}
export function getExternalCircuitListObjects( export function getExternalCircuitListObjects(
projectId: string, projectId: string,
circuitListId: string circuitListId: string
@ -484,57 +508,6 @@ export function getExternalCircuitListObjects(
); );
} }
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 planExternalInitialImport(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<ExternalInitialImportPlanDto>(
`/api/projects/${projectId}/external-csv/initial-import/plan`,
{
method: "POST",
body: JSON.stringify({ fileName, contentBase64 }),
}
);
}
export function applyExternalInitialImport(
projectId: string,
input: ApplyExternalInitialImportInput
) {
return request<ExternalInitialImportResultDto>(
`/api/projects/${projectId}/external-csv/initial-import/apply`,
{ method: "POST", body: JSON.stringify(input) }
);
}
export function copyDistributionBoard( export function copyDistributionBoard(
projectId: string, projectId: string,
distributionBoardId: string, distributionBoardId: string,

View file

@ -62,6 +62,7 @@ const commandTypeLabels: Record<string, string> = {
"project.restore-state": "Projektstand wiederhergestellt", "project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet", "circuit-protection.update": "Stromkreisschutz bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet", "external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen", "external-import.apply-initial": "Revit-Erstimport übernommen",
"external-object.assign-to-new-circuit": "external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen", "Externes Objekt in neuen Stromkreis übernommen",

View file

@ -0,0 +1,17 @@
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
export function createDefaultRevitXlsxConfiguration(): ExternalModelConfiguration {
return {
schemaVersion: 1,
layout: { headerRow: 1, firstDataRow: 3, firstDataColumn: 1 },
circuitMode: "combined",
columns: {
worksharingId: "Worksharing-ID", ifcGuid: "IfcGUID", roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name", familyAndType: "Familie und Typ", selectionMarker: "CAx_Auswahlkenner",
socketCount: "CAX_AnzahlObjekte", sourceRemark: "CAx_Anmerkung", circuitCombined: "kbp_Stromkreisnummer",
boardName: "kbp-E-Verteiler", circuitIdentifier: "kbp-E-Stromkreis", designation: "kbp-E-Bezeichnung",
electricalPowerW: "kbp-E-Elektrische Leistung", voltageV: "kbp-E-Spannung", currentA: "kbp-E-Stromstärke",
},
deviceTypes: [], familyTypeRules: [],
};
}

View file

@ -0,0 +1,60 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { createExternalModelConfigurationUpdateProjectCommand } from "../../domain/models/external-model-configuration-project-command.model.js";
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { parseExternalXlsx } from "../../external-model/xlsx/external-xlsx-transport.js";
import { externalModelConfigurationRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export function getExternalModelConfiguration(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
const result = externalModelConfigurationRepository.getByProject(projectId);
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
return res.json({ configuration: result.configuration });
}
export function updateExternalModelConfiguration(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
const expectedRevision = req.body?.expectedRevision;
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
return res.status(400).json({ error: "Ungültige Projektrevision." });
}
try {
assertExternalModelConfiguration(req.body?.configuration);
const current = externalModelConfigurationRepository.getByProject(projectId);
if (!current.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const target = {
id: current.configuration?.id ?? randomUUID(),
projectId,
configuration: req.body.configuration,
};
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Revit-XLSX-Einstellungen gespeichert",
command: createExternalModelConfigurationUpdateProjectCommand(current.configuration, target),
});
return res.json({ configuration: target, history: result.history });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function detectExternalModelWorkbook(req: Request, res: Response) {
try {
assertExternalModelConfiguration(req.body?.configuration);
const document = await parseExternalXlsx({
fileName: req.body?.fileName,
bytes: Buffer.from(req.body?.contentBase64 ?? "", "base64"),
configuration: req.body.configuration,
});
return res.json({
sheetName: document.sheetName,
columnCount: document.columns.length,
objectCount: document.rows.filter((row) => row.classification === "object").length,
});
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Musterdatei konnte nicht gelesen werden." });
}
}

View file

@ -53,6 +53,11 @@ import {
importProjectTransfer, importProjectTransfer,
importProjectTransferAsNewProject, importProjectTransferAsNewProject,
} from "../controllers/project-transfer.controller.js"; } from "../controllers/project-transfer.controller.js";
import {
getExternalModelConfiguration,
detectExternalModelWorkbook,
updateExternalModelConfiguration,
} from "../controllers/external-model-configuration.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
@ -74,6 +79,9 @@ projectRouter.post(
restoreProjectSnapshot restoreProjectSnapshot
); );
projectRouter.put("/:projectId", updateProjectSettings); projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/external-model/configuration", getExternalModelConfiguration);
projectRouter.put("/:projectId/external-model/configuration", updateExternalModelConfiguration);
projectRouter.post("/:projectId/external-model/configuration/detect", detectExternalModelWorkbook);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post( projectRouter.post(

View file

@ -1,251 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
createRevitProjectDevicePrefill,
RevitCsvImportPlan,
RevitCsvModal,
} from "../src/frontend/components/revit-csv-modal.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import { RoomModal } from "../src/frontend/components/room-modal.js";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
getExternalCircuitListObjects,
planExternalInitialImport,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../src/frontend/utils/api.js";
import type { ExternalInitialImportPlanDto } from "../src/frontend/types.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("Revit CSV frontend API", () => {
it("uses the project-scoped configuration, preview and import endpoints", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({ configuration: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
try {
await getExternalCsvConfiguration("project-1");
await getExternalCircuitListObjects("project-1", "list-1");
await updateExternalCsvConfiguration("project-1", 7, configuration);
await previewExternalCsv("project-1", "revit.csv", "YWJj");
await planExternalInitialImport("project-1", "revit.csv", "YWJj");
await applyExternalInitialImport("project-1", {
expectedRevision: 8,
expectedConfigurationVersion: 2,
expectedSha256: "sha",
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
});
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/external-csv/configuration",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/circuit-lists/list-1/external-objects",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/external-csv/configuration",
method: "PUT",
body: { expectedRevision: 7, configuration },
},
{
url: "/api/projects/project-1/external-csv/preview",
method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" },
},
{
url: "/api/projects/project-1/external-csv/initial-import/plan",
method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" },
},
{
url: "/api/projects/project-1/external-csv/initial-import/apply",
method: "POST",
body: {
expectedRevision: 8,
expectedConfigurationVersion: 2,
expectedSha256: "sha",
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
},
},
]);
});
});
describe("Revit CSV modal presentation", () => {
it("uses the viewport-wide dialog variant", () => {
const markup = renderToStaticMarkup(
createElement(RevitCsvModal, {
currentRevision: 0,
onClose: () => undefined,
onCreateProjectDevice: async () => ({} as never),
onCreateRoom: async () => ({} as never),
onRevisionChange: () => undefined,
projectId: "project-1",
})
);
assert.match(markup, /max-width:calc\(100vw - 2rem\)/);
assert.doesNotMatch(markup, /modal-lg/);
});
it("renders the initial import decisions without individual object rows", () => {
const plan: ExternalInitialImportPlanDto = {
configurationVersion: 2,
fileName: "revit.csv",
sha256: "sha",
byteLength: 100,
rowCount: 2,
objectCount: 1,
passthroughCount: 1,
suspectObjectCount: 0,
suspectRowNumbers: [],
sourceRooms: [{
sourceRoomKey: "101|büro",
roomNumber: "101",
roomName: "Büro",
objectCount: 1,
suggestedRoomId: "room-1",
matchStatus: "exact-number",
candidateRoomIds: ["room-1"],
}],
familyGroups: [{
familyAndType: "Steckdose : Standard",
objectCount: 1,
classified: true,
category: "single_phase",
connectionKind: null,
internalDeviceType: "socket",
}],
issueCounts: { unknownFamilyAndType: 0, invalidPower: 0, invalidQuantity: 0, missingRoom: 0 },
objects: [],
existing: {
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro", areaM2: null }],
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
},
};
const markup = renderToStaticMarkup(createElement(RevitCsvImportPlan, {
familyLinks: { "Steckdose : Standard": "device-1" },
isApplying: false,
onApply: () => undefined,
onCreateProjectDevice: () => undefined,
onCreateRoom: () => undefined,
onFamilyLinkChange: () => undefined,
onRoomDecisionChange: () => undefined,
plan,
roomDecisions: { "101|büro": { roomId: "room-1", defaultDistributionBoardId: "board-1" } },
setSourceName: () => undefined,
sourceName: "Revit-Gesamtmodell",
}));
assert.match(markup, /Erstimport vorbereiten/);
assert.match(markup, /101 · Büro/);
assert.match(markup, /Steckdose : Standard/);
assert.match(markup, /Erstimport übernehmen/);
assert.match(markup, /Raum 101 neu anlegen/);
assert.match(markup, /ProjectDevice für Steckdose : Standard neu anlegen/);
assert.doesNotMatch(markup, /IFC-GUID/);
});
it("prefills the existing room and ProjectDevice forms from Revit decisions", () => {
const roomMarkup = renderToStaticMarkup(createElement(RoomModal, {
floors: [{ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 0 }],
initialValues: { roomNumber: "101", roomName: "Büro" },
isSaving: false,
onClose: () => undefined,
onSave: async () => undefined,
}));
const deviceMarkup = renderToStaticMarkup(createElement(ProjectDeviceModal, {
globalDevices: [],
initialValues: {
name: "socket",
displayName: "Steckdose",
category: "single_phase",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
}));
assert.match(roomMarkup, /value="101"/);
assert.match(roomMarkup, /value="Büro"/);
assert.match(deviceMarkup, /value="socket"/);
assert.match(deviceMarkup, /value="Steckdose"/);
assert.match(deviceMarkup, /value="2"/);
});
it("converts mapped Revit watts into ProjectDevice kilowatts", () => {
const plan = {
familyGroups: [{
familyAndType: "Steckdose : Standard",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "230 V",
}],
objects: [{
sourceValues: { familyAndType: "Steckdose : Standard" },
suggestedPlanningValues: {
displayName: "Arbeitsplatzsteckdose",
effectiveQuantity: 4,
powerPerUnitW: 250,
simultaneityFactor: 0.8,
cosPhi: 0.95,
costGroup: "440",
remark: "Aus Revit",
},
}],
} as ExternalInitialImportPlanDto;
assert.deepEqual(
createRevitProjectDevicePrefill(plan, "Steckdose : Standard"),
{
name: "socket",
displayName: "Arbeitsplatzsteckdose",
connectionKind: "230 V",
category: "single_phase",
quantity: 4,
powerPerUnit: 0.25,
simultaneityFactor: 0.8,
cosPhi: 0.95,
costGroup: "440",
remark: "Aus Revit",
}
);
});
});

View file

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { detectExternalModelWorkbook, getExternalModelConfiguration, updateExternalModelConfiguration } from "../src/frontend/utils/api.js";
describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify({ configuration: null, history: { currentRevision: 8 } }), { status: 200, headers: { "Content-Type": "application/json" } });
};
try {
const configuration = createDefaultExternalModelConfiguration();
await getExternalModelConfiguration("project-1");
await updateExternalModelConfiguration("project-1", 7, configuration);
await detectExternalModelWorkbook("project-1", new File([new Uint8Array([1, 2])], "sample.xlsx"), configuration);
assert.deepEqual(requests, [
{ url: "/api/projects/project-1/external-model/configuration", method: "GET", body: undefined },
{ url: "/api/projects/project-1/external-model/configuration", method: "PUT", body: { expectedRevision: 7, configuration } },
{ url: "/api/projects/project-1/external-model/configuration/detect", method: "POST", body: { fileName: "sample.xlsx", contentBase64: "AQI=", configuration } },
]);
} finally { globalThis.fetch = originalFetch; }
});
it("exposes the Revit route and removes the old project modal", () => {
const projectPage = readFileSync("src/app/projects/[projectId]/page.tsx", "utf8");
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
assert.match(revitPage, /Importstatus/);
assert.match(settings, /Musterdatei prüfen/);
assert.match(settings, /Kombinierte Stromkreisspalte/);
});
});