forked from jappel/leistungsbilanz-ts
Add Revit XLSX settings page
This commit is contained in:
parent
1cfeb06ec7
commit
c633e78cd4
15 changed files with 259 additions and 1025 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
src/app/projects/[projectId]/revit/page.tsx
Normal file
34
src/app/projects/[projectId]/revit/page.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
45
src/frontend/components/revit-xlsx-settings-modal.tsx
Normal file
45
src/frontend/components/revit-xlsx-settings-modal.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
17
src/frontend/utils/revit-xlsx-settings.ts
Normal file
17
src/frontend/utils/revit-xlsx-settings.ts
Normal 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
38
tests/revit-xlsx-settings-api.test.ts
Normal file
38
tests/revit-xlsx-settings-api.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue