From a9d630175363f7d0ef0ef8f95fe8955b341ccec9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 16:59:18 +0200 Subject: [PATCH] Add Revit CSV project dialog --- AGENTS.md | 5 + docs/current-architecture.md | 10 +- .../spec/revit-csv-phase-14-audit-and-plan.md | 7 +- src/app/projects/[projectId]/page.tsx | 18 ++ src/frontend/components/revit-csv-modal.tsx | 220 ++++++++++++++++++ src/frontend/types.ts | 40 ++++ src/frontend/utils/api.ts | 36 +++ tests/revit-csv-frontend-api.test.ts | 54 +++++ 8 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 src/frontend/components/revit-csv-modal.tsx create mode 100644 tests/revit-csv-frontend-api.test.ts diff --git a/AGENTS.md b/AGENTS.md index b18ae4d..7790117 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,11 @@ persisted through the shared project-command transaction, so create, update, delete, Undo and Redo are revision-safe. Snapshot schema 3, 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 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/current-architecture.md b/docs/current-architecture.md index 7c77bee..704b540 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -500,8 +500,8 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren -werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente -externe Objekte und die Revit-GUI sind noch nicht implementiert. +werden Konfigurations-UUID und Projektlink remapped. Persistente externe Objekte +und die bestätigte Importübernahme sind noch nicht implementiert. `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. @@ -509,6 +509,12 @@ Konfigurationsstand serverseitig und verwendet den typisierten Command. Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash, Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten. +Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt, +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. ## Noch nicht unterstützt 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 b99729b..0249f98 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -250,11 +250,14 @@ keine Projektrevision und laufen über einen eigenen Audit-Store. 3. Konfiguration über Migration, Snapshot v3, Transfer und den Command `external-csv-configuration.update` persistieren. **Erledigt.** 4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:** - Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt. **Offen:** - deutscher Projekt-Wizard. Der Server speichert keinen Entwurf: Bei + Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das + deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash gegen die Vorschau geprüft. +**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme +erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2. + Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit `npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 185feb4..d5a6b87 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -59,6 +59,7 @@ import { } from "../../../frontend/components/project-settings-modal"; import { FormModal } from "../../../frontend/components/form-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; +import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -105,6 +106,7 @@ export default function ProjectDetailPage() { const [roomFloorId, setRoomFloorId] = useState(""); const [editingRoom, setEditingRoom] = useState(null); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); + const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); const [structureModal, setStructureModal] = useState< "board" | "floor" | "room" | null >(null); @@ -858,6 +860,14 @@ export default function ProjectDetailPage() {

+ +
+ {isDirty ?
Vor der Vorschau muss die Konfiguration gespeichert werden.
: null} + + + {preview ? : null} + + )} + + ); +} + +function Field({ children, label }: { children: React.ReactNode; label: string }) { + return ; +} + +function AdditionalMappings({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvConfiguration["additionalSourceMappings"]) => void }) { + return

Weitere Quellfelder

{configuration.additionalSourceMappings.map((mapping, index) =>
onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, sourceColumn: event.target.value } : entry))} />
onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, targetField: event.target.value } : entry))} />
)}
; +} + +function FamilyRules({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvFamilyTypeRule[]) => void }) { + const rules = configuration.familyTypeRules; + function change(index: number, patch: Partial) { onChange(rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, ...patch } : rule)); } + return

Familie-und-Typ-Regeln

Exakter Textvergleich; Reihenfolge wird beibehalten.
{rules.map((rule, index) =>
change(index, { exactFamilyAndType: event.target.value })} /> change(index, { internalDeviceType: event.target.value })} /> change(index, { connectionKind: event.target.value || null })} />{rule.quantityRule.kind === "fixed" ? change(index, { quantityRule: { kind: "fixed", quantity: Number(event.target.value) } })} /> : null}{rule.displayNameSuggestion?.kind === "fixed" ? change(index, { displayNameSuggestion: { kind: "fixed", value: event.target.value } })} /> : null}
)}
; +} + +function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) { + const visibleObjects = useMemo(() => preview.objects.slice(0, 25), [preview]); + 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}
; +} + +function Stat({ label, value }: { label: string; value: number }) { return
{label}
{value}
; } + +function fileToBase64(file: File) { + return new Promise((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); + }); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 2fb588c..d950986 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -10,6 +10,46 @@ import type { ProtectionDeviceType, RcdType, } from "../shared/constants/protection-device"; +import type { + ExternalCsvConfiguration, + ExternalCsvDialect, +} from "../external-model/csv/external-csv-contracts"; + +export interface ExternalCsvConfigurationSnapshotDto { + id: string; + projectId: string; + configurationVersion: number; + configuration: ExternalCsvConfiguration; +} + +export interface ExternalCsvPreviewObjectDto { + rowNumber: number; + ifcGuid: string; + roomNumber: string; + roomName: string; + familyAndType: string; + selectionMarker: string; + circuitIdentifier: string; + power: string; + quantity: string | null; + additionalSourceValues: Record; +} + +export interface ExternalCsvPreviewDto { + fileName: string; + byteLength: number; + sha256: string; + dialect: ExternalCsvDialect; + headerRowNumber: number; + headerColumns: string[]; + rowCount: number; + objectCount: number; + passthroughCount: number; + nonEmptyPassthroughCount: number; + suspectObjectCount: number; + objects: ExternalCsvPreviewObjectDto[]; + suspectRowNumbers: number[]; +} export interface ProjectDto { id: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 4e49127..a98a56a 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -26,7 +26,10 @@ import type { ProjectDto, RoomDto, CircuitTreeResponseDto, + ExternalCsvConfigurationSnapshotDto, + ExternalCsvPreviewDto, } from "../types"; +import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; import { createCircuitUpdateProjectCommand, @@ -334,6 +337,39 @@ export function updateDistributionBoard( ); } +export function getExternalCsvConfiguration(projectId: string) { + return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>( + `/api/projects/${projectId}/external-csv/configuration` + ); +} + +export function updateExternalCsvConfiguration( + projectId: string, + expectedRevision: number, + configuration: ExternalCsvConfiguration +) { + return request< + ProjectCommandResultDto & { configuration: ExternalCsvConfigurationSnapshotDto } + >(`/api/projects/${projectId}/external-csv/configuration`, { + method: "PUT", + body: JSON.stringify({ expectedRevision, configuration }), + }); +} + +export function previewExternalCsv( + projectId: string, + fileName: string, + contentBase64: string +) { + return request( + `/api/projects/${projectId}/external-csv/preview`, + { + method: "POST", + body: JSON.stringify({ fileName, contentBase64 }), + } + ); +} + 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 new file mode 100644 index 0000000..e70c71c --- /dev/null +++ b/tests/revit-csv-frontend-api.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + getExternalCsvConfiguration, + previewExternalCsv, + updateExternalCsvConfiguration, +} from "../src/frontend/utils/api.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 () => { + 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 updateExternalCsvConfiguration("project-1", 7, configuration); + await previewExternalCsv("project-1", "revit.csv", "YWJj"); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/external-csv/configuration", + 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" }, + }, + ]); + }); +});