Add Revit CSV project dialog

This commit is contained in:
2026-08-02 16:59:18 +02:00
parent caa6caf99f
commit a9d6301753
8 changed files with 386 additions and 4 deletions
+5
View File
@@ -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 delete, Undo and Redo are revision-safe. Snapshot schema 3, restore and portable
project duplication preserve the configuration; duplication remaps its internal project duplication preserve the configuration; duplication remaps its internal
id and project link. 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 The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per 25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy. project. Named snapshots are never removed by this retention policy.
+8 -2
View File
@@ -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 je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente werden Konfigurations-UUID und Projektlink remapped. Persistente externe Objekte
externe Objekte und die Revit-GUI sind noch nicht implementiert. und die bestätigte Importübernahme sind noch nicht implementiert.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command. 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, Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten. 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 ## Noch nicht unterstützt
@@ -250,11 +250,14 @@ keine Projektrevision und laufen über einen eigenen Audit-Store.
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command 3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
`external-csv-configuration.update` persistieren. **Erledigt.** `external-csv-configuration.update` persistieren. **Erledigt.**
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:** 4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt. **Offen:** Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das
deutscher Projekt-Wizard. Der Server speichert keinen Entwurf: Bei deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
gegen die Vorschau geprüft. 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 Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere `npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
+18
View File
@@ -59,6 +59,7 @@ 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";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = { const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name", name: "Technischer Name",
@@ -105,6 +106,7 @@ export default function ProjectDetailPage() {
const [roomFloorId, setRoomFloorId] = useState(""); const [roomFloorId, setRoomFloorId] = useState("");
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null); const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState< const [structureModal, setStructureModal] = useState<
"board" | "floor" | "room" | null "board" | "floor" | "room" | null
>(null); >(null);
@@ -858,6 +860,14 @@ export default function ProjectDetailPage() {
</p> </p>
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<button
className="btn btn-outline-primary"
disabled={!project}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
>
Revit-CSV
</button>
<button <button
className="btn btn-outline-primary" className="btn btn-outline-primary"
disabled={!project} disabled={!project}
@@ -1678,6 +1688,14 @@ export default function ProjectDetailPage() {
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes} usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/> />
) : null} ) : null}
{project && isRevitCsvOpen ? (
<RevitCsvModal
currentRevision={project.currentRevision}
onClose={() => setIsRevitCsvOpen(false)}
onRevisionChange={applyProjectRevision}
projectId={projectId}
/>
) : null}
</main> </main>
); );
} }
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
createDefaultExternalCsvConfiguration,
type ExternalCsvConfiguration,
type ExternalCsvFamilyTypeRule,
} from "../../external-model/csv/external-csv-contracts";
import type { ExternalCsvPreviewDto } from "../types";
import {
getExternalCsvConfiguration,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../utils/api";
import { FormModal } from "./form-modal";
interface RevitCsvModalProps {
currentRevision: number;
onClose: () => void;
onRevisionChange: (currentRevision: number) => void;
projectId: string;
}
const defaultConfiguration = createDefaultExternalCsvConfiguration({
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "kbp_Stromkreisnummer",
power: "kbp-E-Elektrische Leistung",
quantity: null,
});
defaultConfiguration.additionalSourceMappings = [
{ sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" },
{ sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" },
{ sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" },
{ sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" },
{ sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" },
];
const columnFields = [
["ifcGuid", "IFC-GUID"],
["roomNumber", "Raumnummer"],
["roomName", "Raumname"],
["familyAndType", "Familie und Typ"],
["selectionMarker", "CAx-Auswahlkenner"],
["circuitIdentifier", "Stromkreiskennzeichnung"],
["power", "Elektrische Leistung"],
["quantity", "Menge (optional)"],
] as const;
export function RevitCsvModal({
currentRevision,
onClose,
onRevisionChange,
projectId,
}: RevitCsvModalProps) {
const [configuration, setConfiguration] = useState<ExternalCsvConfiguration>(
structuredClone(defaultConfiguration)
);
const [savedConfiguration, setSavedConfiguration] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ExternalCsvPreviewDto | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getExternalCsvConfiguration(projectId)
.then(({ configuration: stored }) => {
const next = stored?.configuration ?? structuredClone(defaultConfiguration);
setConfiguration(next);
setSavedConfiguration(stored ? JSON.stringify(next) : null);
})
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht geladen werden.")
)
.finally(() => setIsLoading(false));
}, [projectId]);
const isDirty = savedConfiguration !== JSON.stringify(configuration);
function replaceConfiguration(next: ExternalCsvConfiguration) {
setConfiguration(next);
setPreview(null);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSaving(true);
setError(null);
try {
const result = await updateExternalCsvConfiguration(
projectId,
currentRevision,
configuration
);
setSavedConfiguration(JSON.stringify(result.configuration.configuration));
onRevisionChange(result.history.currentRevision);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handlePreview() {
if (!file || isDirty || savedConfiguration === null) return;
setIsPreviewing(true);
setError(null);
try {
const contentBase64 = await fileToBase64(file);
setPreview(await previewExternalCsv(projectId, file.name, contentBase64));
} catch (reason) {
setPreview(null);
setError(reason instanceof Error ? reason.message : "CSV-Vorschau konnte nicht erstellt werden.");
} finally {
setIsPreviewing(false);
}
}
function updateColumn(key: (typeof columnFields)[number][0], value: string) {
setConfiguration((current) => ({
...current,
columns: { ...current.columns, [key]: key === "quantity" && !value.trim() ? null : value },
}));
setPreview(null);
}
return (
<FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSave}
submitDisabled={isLoading || isPreviewing || !isDirty}
submitLabel="Konfiguration speichern"
title="Revit-CSV"
>
{error ? <div className="alert alert-warning">{error}</div> : null}
{isLoading ? <p>Konfiguration wird geladen </p> : (
<div className="d-grid gap-4">
<section>
<h3 className="h6">Transport</h3>
<div className="row g-3">
<Field label="Encoding"><select className="form-select" disabled value={configuration.encoding}><option value="utf-8">UTF-8</option></select></Field>
<Field label="Trennzeichen"><select className="form-select" value={configuration.delimiter} onChange={(event) => replaceConfiguration({ ...configuration, delimiter: event.target.value as ExternalCsvConfiguration["delimiter"] })}><option value=";">Semikolon (;)</option><option value=",">Komma (,)</option><option value="\t">Tabulator</option></select></Field>
<Field label="Dezimaltrennzeichen"><select className="form-select" value={configuration.decimalSeparator} onChange={(event) => replaceConfiguration({ ...configuration, decimalSeparator: event.target.value as "," | "." })}><option value=",">Komma</option><option value=".">Punkt</option></select></Field>
<Field label="Leistungseinheit"><select className="form-select" value={configuration.powerUnit} onChange={(event) => replaceConfiguration({ ...configuration, powerUnit: event.target.value as "W" | "kW", wattsPerSourceUnit: event.target.value === "kW" ? 1000 : 1 })}><option value="W">Watt</option><option value="kW">Kilowatt</option></select></Field>
<Field label="Umrechnung in Watt"><input className="form-control" min="0.000001" step="any" type="number" value={configuration.wattsPerSourceUnit} onChange={(event) => replaceConfiguration({ ...configuration, wattsPerSourceUnit: Number(event.target.value) })} /></Field>
</div>
</section>
<section>
<h3 className="h6">Spaltenzuordnung</h3>
<div className="row g-3">
{columnFields.map(([key, label]) => (
<Field key={key} label={label}>
<input className="form-control" value={configuration.columns[key] ?? ""} onChange={(event) => updateColumn(key, event.target.value)} />
</Field>
))}
</div>
</section>
<AdditionalMappings configuration={configuration} onChange={(additionalSourceMappings) => replaceConfiguration({ ...configuration, additionalSourceMappings })} />
<FamilyRules configuration={configuration} onChange={(familyTypeRules) => replaceConfiguration({ ...configuration, familyTypeRules })} />
<section className="border-top pt-3">
<h3 className="h6">CSV prüfen</h3>
<p className="small text-secondary">Die Vorschau speichert keine Datei und verändert keine Projektdaten.</p>
<div className="d-flex flex-wrap gap-2 align-items-center">
<input accept=".csv,text/csv" className="form-control" onChange={(event) => { setFile(event.target.files?.[0] ?? null); setPreview(null); }} style={{ maxWidth: 480 }} type="file" />
<button className="btn btn-outline-primary" disabled={!file || isDirty || savedConfiguration === null || isPreviewing || isSaving} onClick={handlePreview} type="button">{isPreviewing ? "Wird geprüft …" : "Vorschau erstellen"}</button>
</div>
{isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null}
</section>
{preview ? <PreviewResult preview={preview} /> : null}
</div>
)}
</FormModal>
);
}
function Field({ children, label }: { children: React.ReactNode; label: string }) {
return <label className="col-12 col-md-6"><span className="form-label">{label}</span>{children}</label>;
}
function AdditionalMappings({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvConfiguration["additionalSourceMappings"]) => void }) {
return <section><div className="d-flex justify-content-between align-items-center"><h3 className="h6 mb-0">Weitere Quellfelder</h3><button className="btn btn-sm btn-outline-secondary" onClick={() => onChange([...configuration.additionalSourceMappings, { sourceColumn: "", targetField: "" }])} type="button">Zuordnung hinzufügen</button></div><div className="d-grid gap-2 mt-2">{configuration.additionalSourceMappings.map((mapping, index) => <div className="row g-2" key={index}><div className="col"><input aria-label={`Quellspalte ${index + 1}`} className="form-control" placeholder="CSV-Spalte" value={mapping.sourceColumn} onChange={(event) => onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, sourceColumn: event.target.value } : entry))} /></div><div className="col"><input aria-label={`Zielfeld ${index + 1}`} className="form-control" placeholder="Internes Zielfeld" value={mapping.targetField} onChange={(event) => onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, targetField: event.target.value } : entry))} /></div><div className="col-auto"><button aria-label={`Zuordnung ${index + 1} entfernen`} className="btn btn-outline-danger" onClick={() => onChange(configuration.additionalSourceMappings.filter((_, entryIndex) => entryIndex !== index))} type="button">Entfernen</button></div></div>)}</div></section>;
}
function FamilyRules({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvFamilyTypeRule[]) => void }) {
const rules = configuration.familyTypeRules;
function change(index: number, patch: Partial<ExternalCsvFamilyTypeRule>) { onChange(rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, ...patch } : rule)); }
return <section><div className="d-flex justify-content-between align-items-center"><div><h3 className="h6 mb-0">Familie-und-Typ-Regeln</h3><div className="form-text">Exakter Textvergleich; Reihenfolge wird beibehalten.</div></div><button className="btn btn-sm btn-outline-secondary" onClick={() => onChange([...rules, { exactFamilyAndType: "", internalDeviceType: "", connectionKind: null, category: "single_phase", quantityRule: { kind: "fixed", quantity: 1 }, displayNameSuggestion: null }])} type="button">Regel hinzufügen</button></div><div className="d-grid gap-3 mt-2">{rules.map((rule, index) => <div className="border rounded p-3" key={index}><div className="row g-2"><Field label="Exakter Familie-und-Typ-Wert"><input className="form-control" value={rule.exactFamilyAndType} onChange={(event) => change(index, { exactFamilyAndType: event.target.value })} /></Field><Field label="Interner Gerätetyp"><input className="form-control" value={rule.internalDeviceType} onChange={(event) => change(index, { internalDeviceType: event.target.value })} /></Field><Field label="Anschlussart (optional)"><input className="form-control" value={rule.connectionKind ?? ""} onChange={(event) => change(index, { connectionKind: event.target.value || null })} /></Field><Field label="Kategorie"><select className="form-select" value={rule.category} onChange={(event) => change(index, { category: event.target.value as ExternalCsvFamilyTypeRule["category"] })}><option value="lighting">Beleuchtung</option><option value="single_phase">1-phasig</option><option value="three_phase">3-phasig</option></select></Field><Field label="Mengenregel"><select className="form-select" value={rule.quantityRule.kind} onChange={(event) => change(index, { quantityRule: event.target.value === "mapped-column" ? { kind: "mapped-column" } : { kind: "fixed", quantity: 1 } })}><option value="fixed">Feste Menge</option><option disabled={configuration.columns.quantity === null} value="mapped-column">Aus Mengenspalte</option></select></Field>{rule.quantityRule.kind === "fixed" ? <Field label="Feste Menge"><input className="form-control" min="0.001" step="any" type="number" value={rule.quantityRule.quantity} onChange={(event) => change(index, { quantityRule: { kind: "fixed", quantity: Number(event.target.value) } })} /></Field> : null}<Field label="Anzeigename vorschlagen aus"><select className="form-select" value={rule.displayNameSuggestion?.kind ?? "none"} onChange={(event) => change(index, { displayNameSuggestion: event.target.value === "none" ? null : event.target.value === "fixed" ? { kind: "fixed", value: "" } : { kind: event.target.value as "selection-marker" | "family-and-type" } })}><option value="none">Kein Vorschlag</option><option value="selection-marker">CAx-Auswahlkenner</option><option value="family-and-type">Familie und Typ</option><option value="fixed">Fester Text</option></select></Field>{rule.displayNameSuggestion?.kind === "fixed" ? <Field label="Fester Anzeigename"><input className="form-control" value={rule.displayNameSuggestion.value} onChange={(event) => change(index, { displayNameSuggestion: { kind: "fixed", value: event.target.value } })} /></Field> : null}</div><button className="btn btn-sm btn-outline-danger mt-2" onClick={() => onChange(rules.filter((_, ruleIndex) => ruleIndex !== index))} type="button">Regel entfernen</button></div>)}</div></section>;
}
function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) {
const visibleObjects = useMemo(() => preview.objects.slice(0, 25), [preview]);
return <section className="border-top pt-3"><h3 className="h6">Prüfergebnis</h3><div className="row g-2 mb-3"><Stat label="Objekte" value={preview.objectCount} /><Stat label="Passthrough-Zeilen" value={preview.passthroughCount} /><Stat label="Verdachtszeilen" value={preview.suspectObjectCount} /><Stat label="Kopfzeile" value={preview.headerRowNumber} /></div><p className="small text-secondary text-break mb-2">SHA-256: {preview.sha256}</p><div className="table-responsive"><table className="table table-sm"><thead><tr><th>Zeile</th><th>IFC-GUID</th><th>Raum</th><th>Familie und Typ</th><th>Auswahlkenner</th><th>Stromkreis</th></tr></thead><tbody>{visibleObjects.map((object) => <tr key={object.ifcGuid}><td>{object.rowNumber}</td><td><code>{object.ifcGuid}</code></td><td>{[object.roomNumber, object.roomName].filter(Boolean).join(" · ") || ""}</td><td>{object.familyAndType || ""}</td><td>{object.selectionMarker || ""}</td><td>{object.circuitIdentifier || ""}</td></tr>)}</tbody></table></div>{preview.objects.length > visibleObjects.length ? <p className="small text-secondary">Es werden die ersten {visibleObjects.length} von {preview.objects.length} Objekten angezeigt.</p> : null}</section>;
}
function Stat({ label, value }: { label: string; value: number }) { return <div className="col-6 col-lg-3"><div className="border rounded p-2"><div className="small text-secondary">{label}</div><strong>{value}</strong></div></div>; }
function fileToBase64(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("CSV-Datei konnte nicht gelesen werden."));
reader.onload = () => {
const result = String(reader.result ?? "");
const commaIndex = result.indexOf(",");
if (commaIndex < 0) reject(new Error("CSV-Datei konnte nicht kodiert werden."));
else resolve(result.slice(commaIndex + 1));
};
reader.readAsDataURL(file);
});
}
+40
View File
@@ -10,6 +10,46 @@ import type {
ProtectionDeviceType, ProtectionDeviceType,
RcdType, RcdType,
} from "../shared/constants/protection-device"; } from "../shared/constants/protection-device";
import type {
ExternalCsvConfiguration,
ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts";
export interface ExternalCsvConfigurationSnapshotDto {
id: string;
projectId: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
}
export interface ExternalCsvPreviewObjectDto {
rowNumber: number;
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export interface ExternalCsvPreviewDto {
fileName: string;
byteLength: number;
sha256: string;
dialect: ExternalCsvDialect;
headerRowNumber: number;
headerColumns: string[];
rowCount: number;
objectCount: number;
passthroughCount: number;
nonEmptyPassthroughCount: number;
suspectObjectCount: number;
objects: ExternalCsvPreviewObjectDto[];
suspectRowNumbers: number[];
}
export interface ProjectDto { export interface ProjectDto {
id: string; id: string;
+36
View File
@@ -26,7 +26,10 @@ import type {
ProjectDto, ProjectDto,
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto,
} from "../types"; } from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import { import {
createCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand,
@@ -334,6 +337,39 @@ export function updateDistributionBoard(
); );
} }
export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration`
);
}
export function updateExternalCsvConfiguration(
projectId: string,
expectedRevision: number,
configuration: ExternalCsvConfiguration
) {
return request<
ProjectCommandResultDto & { configuration: ExternalCsvConfigurationSnapshotDto }
>(`/api/projects/${projectId}/external-csv/configuration`, {
method: "PUT",
body: JSON.stringify({ expectedRevision, configuration }),
});
}
export function previewExternalCsv(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<ExternalCsvPreviewDto>(
`/api/projects/${projectId}/external-csv/preview`,
{
method: "POST",
body: JSON.stringify({ fileName, contentBase64 }),
}
);
}
export function copyDistributionBoard( export function copyDistributionBoard(
projectId: string, projectId: string,
distributionBoardId: string, distributionBoardId: string,
+54
View File
@@ -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" },
},
]);
});
});