diff --git a/AGENTS.md b/AGENTS.md index b892a9a..5f6473d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,11 +266,12 @@ persisted through the shared project-command transaction, so create, update, delete, Undo and Redo are revision-safe. Snapshot schema 4, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. -The project page exposes the current Phase 14.1 Revit CSV modal. It edits the -complete transport/column/family-rule configuration through the revision-safe -endpoint and requests a stateless preview. Preview responses include all mapped -objects, while the UI renders only the first 25. No preview creates external -objects, rows, revisions or server-side drafts. +The project page exposes the Revit CSV modal. It edits the complete +transport/column/family-rule configuration through the revision-safe endpoint, +requests a stateless preview and then shows the Phase 14.2 initial-import +decisions. Preview responses include all mapped objects, while the UI renders +only the first 25. No preview or plan creates external objects, rows, revisions +or server-side drafts. Phase 14.2 migration `0003` provides one implicit Revit CSV source per project, immutable import batches with original bytes plus the classified cell matrix, normalized source-room mappings and external objects unique by @@ -287,15 +288,14 @@ Confirmed initial state is written only through original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit planning overrides and internal project links in the shared transaction. It never creates or links a CircuitDeviceRow. Its exact inverse removes the whole -unchanged external state, and Redo restores the same UUIDs and bytes. No -dedicated file-and-decisions apply endpoint or staging wizard invokes this -command yet. +unchanged external state, and Redo restores the same UUIDs and bytes. `POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless read path for the first wizard stage. It rejects projects with an existing external source and returns source-room groups, exact room-number suggestions, exact family/type groups, projected object values, issue counts and the current floor/room/board/ProjectDevice catalogs. It creates no draft, revision or -domain row; the decision UI is still pending. +domain row. The wide modal renders these groups and catalogs as explicit +decisions and identifies exact, ambiguous and new room matches. The dedicated `POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint retransmits and replans the file against the expected hash, configuration @@ -303,8 +303,9 @@ version and project revision. It requires one explicit decision per source-room and exact family/type group, blocks unclassified families, creates all stable external UUIDs server-side and invokes `external-import.apply-initial`. Room/default-board and optional ProjectDevice links are supported; CSV circuit -values remain source-only and every `circuitDeviceRowId` remains null. The -decision UI is still pending. +values remain source-only and every `circuitDeviceRowId` remains null. The UI +blocks unclassified families, requires a final confirmation and applies the +whole import as one persistent Undo/Redo step. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 206f967..70b5bb5 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -404,7 +404,17 @@ project device never triggers synchronization implicitly. - returns transport metadata, SHA-256, row classifications, suspect row numbers and mapped source values for every recognized IFC object - does not persist a draft, mutate project state or create a revision +- `POST /projects/:projectId/external-csv/initial-import/plan` + - reparses the file and returns grouped room/family decisions plus current + room, distribution-board and ProjectDevice catalogs + - is stateless and rejects an already imported external source +- `POST /projects/:projectId/external-csv/initial-import/apply` + - requires the expected revision, configuration version, file hash and one + decision for every room and exact family/type group + - reparses the file, blocks unknown family/type values and executes + `external-import.apply-initial` as one undoable project revision + - creates no Circuit or CircuitDeviceRow Parser errors expose stable codes such as `header-not-found`, -`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. A later -confirmed import must upload and parse the file again and verify its hash. +`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed +import uploads and parses the file again and verifies its hash. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 85fd206..52949b1 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,7 +9,8 @@ - Named and automatic logical project snapshots can be listed and restored through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. -- No Revit/CSV/IFCGUID import and export workflow is implemented yet. +- The Revit/CSV foundation and confirmed initial import are implemented. Row + assignment, follow-up import, conflict review and return export are pending. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index fd94776..a09cda3 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -527,9 +527,8 @@ validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt. Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand, -Redo verwendet dieselben UUIDs und Bytes. Ein aufrufender Import-Endpunkt und -der mehrstufige Wizard sind noch nicht implementiert; bloße Vorschau erzeugt -weiterhin keine dieser Zeilen. +Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung +erzeugen weiterhin keine dieser Zeilen. `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder ändern die Konfiguration; der PUT plant Identität und nächsten Konfigurationsstand serverseitig und verwendet den typisierten Command. @@ -560,13 +559,18 @@ Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln. Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der -Response verfügbar, ohne alle Zeilen in den DOM einzufügen. +Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende +Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er +zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume, +Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die +Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog +wird der vollständige Import als eine persistente, rückgängig machbare +Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen. ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- bestätigter Revit-Erstimport und vollständiger CSV-/IFCGUID-Round-trip über - die vorhandene Transport- und Persistenzgrundlage hinaus +- Folgeimport, Editor-Zuordnung und strukturtreuer Revit-Rückexport - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 84e0132..b665e85 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -532,8 +532,8 @@ Acceptance criteria: ## Phase 14: External Model Round-Trip -Status: Phase 14.0 (Audit und Verträge) ist dokumentiert und wartet vor der -Laufzeitimplementierung auf Freigabe. Siehe +Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist +implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe [Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md) und die verbindlichen [Revit-CSV-Anforderungen](revit-csv-integration-requirements.md). diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index 645c42d..ee2b2cc 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -288,19 +288,20 @@ einen Commit aufgenommen. die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` bleibt unverändert. 4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard - ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Begonnen:** + ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:** Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge, Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und - schreibt weder Entwurf noch Projektzustand. Entscheidungs-UI und Apply- - Endpunkt stehen noch aus. **Backend-Apply erledigt:** Der dedizierte + schreibt weder Entwurf noch Projektzustand. Der dedizierte Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die Planung, vollständige Entscheidungen für alle Quellräume und exakten Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben - ausgeschlossen. Die Entscheidungs-UI steht noch aus. + ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen, + bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und + verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung statt serverseitiger Entwürfe, die Konfigurationsversionierung und die diff --git a/src/frontend/components/revit-csv-modal.tsx b/src/frontend/components/revit-csv-modal.tsx index 35affc1..f56f0f3 100644 --- a/src/frontend/components/revit-csv-modal.tsx +++ b/src/frontend/components/revit-csv-modal.tsx @@ -6,9 +6,11 @@ import { type ExternalCsvConfiguration, type ExternalCsvFamilyTypeRule, } from "../../external-model/csv/external-csv-contracts"; -import type { ExternalCsvPreviewDto } from "../types"; +import type { ExternalCsvPreviewDto, ExternalInitialImportPlanDto } from "../types"; import { + applyExternalInitialImport, getExternalCsvConfiguration, + planExternalInitialImport, previewExternalCsv, updateExternalCsvConfiguration, } from "../utils/api"; @@ -62,9 +64,16 @@ export function RevitCsvModal({ const [savedConfiguration, setSavedConfiguration] = useState(null); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); + const [importPlan, setImportPlan] = useState(null); + const [roomDecisions, setRoomDecisions] = useState>({}); + const [familyLinks, setFamilyLinks] = useState>({}); + const [sourceName, setSourceName] = useState("Revit-Gesamtmodell"); + const [success, setSuccess] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isPreviewing, setIsPreviewing] = useState(false); + const [isPlanning, setIsPlanning] = useState(false); + const [isApplying, setIsApplying] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -85,6 +94,8 @@ export function RevitCsvModal({ function replaceConfiguration(next: ExternalCsvConfiguration) { setConfiguration(next); setPreview(null); + setImportPlan(null); + setSuccess(null); } async function handleSave(event: FormEvent) { @@ -98,6 +109,7 @@ export function RevitCsvModal({ 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."); @@ -113,6 +125,8 @@ export function RevitCsvModal({ 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."); @@ -121,26 +135,93 @@ export function RevitCsvModal({ } } + 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); } return ( {error ?
{error}
: null} + {success ?
{success}
: null} {isLoading ?

Konfiguration wird geladen …

: (
@@ -172,13 +253,27 @@ export function RevitCsvModal({

CSV prüfen

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

- { setFile(event.target.files?.[0] ?? null); setPreview(null); }} style={{ maxWidth: 480 }} type="file" /> + { setFile(event.target.files?.[0] ?? null); setPreview(null); setImportPlan(null); setSuccess(null); }} style={{ maxWidth: 480 }} type="file" /> +
{isDirty ?
Vor der Vorschau muss die Konfiguration gespeichert werden.
: null}
{preview ? : null} + {importPlan ? ( + setFamilyLinks((current) => ({ ...current, [familyAndType]: projectDeviceId }))} + onRoomDecisionChange={(sourceRoomKey, decision) => setRoomDecisions((current) => ({ ...current, [sourceRoomKey]: decision }))} + plan={importPlan} + roomDecisions={roomDecisions} + setSourceName={setSourceName} + sourceName={sourceName} + /> + ) : null}
)}
@@ -204,6 +299,193 @@ function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) { return

Prüfergebnis

SHA-256: {preview.sha256}

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

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

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

Erstimport vorbereiten

+

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

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

Räume zuordnen

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

Familien und Typen zuordnen

+
+ + + + {plan.familyGroups.map((group) => ( + + + + + + + ))} + +
Familie und TypObjekteKlassifizierungProjectDevice (optional)
{group.familyAndType || "–"}{group.objectCount} + {group.classified + ? `${group.internalDeviceType ?? "–"} · ${categoryLabel(group.category)}` + : Regel fehlt} + + +
+
+ +
+ +
+
+ ); +} + +function RoomMatchBadge({ status }: { status: ExternalInitialImportPlanDto["sourceRooms"][number]["matchStatus"] }) { + if (status === "exact-number") return Eindeutig; + if (status === "ambiguous-number") return Mehrdeutig; + return Neu; +} + +function categoryLabel(category: string | null) { + if (category === "lighting") return "Beleuchtung"; + if (category === "single_phase") return "1-phasig"; + if (category === "three_phase") return "3-phasig"; + return "Ohne Kategorie"; +} + function Stat({ label, value }: { label: string; value: number }) { return
{label}
{value}
; } function fileToBase64(file: File) { diff --git a/src/frontend/types.ts b/src/frontend/types.ts index d950986..feea899 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -14,6 +14,7 @@ import type { ExternalCsvConfiguration, ExternalCsvDialect, } from "../external-model/csv/external-csv-contracts"; +import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan"; export interface ExternalCsvConfigurationSnapshotDto { id: string; @@ -51,6 +52,36 @@ export interface ExternalCsvPreviewDto { suspectRowNumbers: number[]; } +export type ExternalInitialImportPlanDto = ReturnType< + typeof createExternalInitialImportPlan +>; + +export interface ApplyExternalInitialImportInput { + expectedRevision: number; + expectedConfigurationVersion: number; + expectedSha256: string; + fileName: string; + contentBase64: string; + sourceName: string; + roomDecisions: Array<{ + sourceRoomKey: string; + roomId: string | null; + defaultDistributionBoardId: string | null; + }>; + familyProjectDeviceDecisions: Array<{ + familyAndType: string; + projectDeviceId: string | null; + }>; +} + +export interface ExternalInitialImportResultDto extends ProjectCommandResultDto { + import: { + sourceId: string; + importBatchId: string; + objectCount: number; + }; +} + export interface ProjectDto { id: string; name: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index a98a56a..fcc8e94 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -28,6 +28,9 @@ import type { CircuitTreeResponseDto, ExternalCsvConfigurationSnapshotDto, ExternalCsvPreviewDto, + ExternalInitialImportPlanDto, + ApplyExternalInitialImportInput, + ExternalInitialImportResultDto, } from "../types"; import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; @@ -370,6 +373,30 @@ export function previewExternalCsv( ); } +export function planExternalInitialImport( + projectId: string, + fileName: string, + contentBase64: string +) { + return request( + `/api/projects/${projectId}/external-csv/initial-import/plan`, + { + method: "POST", + body: JSON.stringify({ fileName, contentBase64 }), + } + ); +} + +export function applyExternalInitialImport( + projectId: string, + input: ApplyExternalInitialImportInput +) { + return request( + `/api/projects/${projectId}/external-csv/initial-import/apply`, + { method: "POST", body: JSON.stringify(input) } + ); +} + export function copyDistributionBoard( projectId: string, distributionBoardId: string, diff --git a/tests/revit-csv-frontend-api.test.ts b/tests/revit-csv-frontend-api.test.ts index e08c1e2..85846d7 100644 --- a/tests/revit-csv-frontend-api.test.ts +++ b/tests/revit-csv-frontend-api.test.ts @@ -2,17 +2,23 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { RevitCsvModal } from "../src/frontend/components/revit-csv-modal.js"; import { + RevitCsvImportPlan, + RevitCsvModal, +} from "../src/frontend/components/revit-csv-modal.js"; +import { + applyExternalInitialImport, getExternalCsvConfiguration, + 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 and preview endpoints", async () => { + 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) => { @@ -32,6 +38,17 @@ describe("Revit CSV frontend API", () => { await getExternalCsvConfiguration("project-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; } @@ -52,6 +69,25 @@ describe("Revit CSV frontend API", () => { 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" }], + }, + }, ]); }); }); @@ -70,4 +106,60 @@ describe("Revit CSV modal presentation", () => { 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" }], + 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, + 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.doesNotMatch(markup, /IFC-GUID/); + }); });