Add Revit CSV project dialog
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProjectDeviceSyncField, string> = {
|
||||
name: "Technischer Name",
|
||||
@@ -105,6 +106,7 @@ export default function ProjectDetailPage() {
|
||||
const [roomFloorId, setRoomFloorId] = useState("");
|
||||
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(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() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
onClick={() => setIsRevitCsvOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
Revit-CSV
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
@@ -1678,6 +1688,14 @@ export default function ProjectDetailPage() {
|
||||
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
|
||||
/>
|
||||
) : null}
|
||||
{project && isRevitCsvOpen ? (
|
||||
<RevitCsvModal
|
||||
currentRevision={project.currentRevision}
|
||||
onClose={() => setIsRevitCsvOpen(false)}
|
||||
onRevisionChange={applyProjectRevision}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<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 {
|
||||
id: string;
|
||||
|
||||
@@ -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<ExternalCsvPreviewDto>(
|
||||
`/api/projects/${projectId}/external-csv/preview`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fileName, contentBase64 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function copyDistributionBoard(
|
||||
projectId: string,
|
||||
distributionBoardId: string,
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user