Add Revit initial import wizard

This commit is contained in:
2026-08-02 17:52:21 +02:00
parent 8646f346e6
commit ab2449419b
10 changed files with 481 additions and 32 deletions
+12 -11
View File
@@ -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 delete, Undo and Redo are revision-safe. Snapshot schema 4, 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 The project page exposes the Revit CSV modal. It edits the complete
complete transport/column/family-rule configuration through the revision-safe transport/column/family-rule configuration through the revision-safe endpoint,
endpoint and requests a stateless preview. Preview responses include all mapped requests a stateless preview and then shows the Phase 14.2 initial-import
objects, while the UI renders only the first 25. No preview creates external decisions. Preview responses include all mapped objects, while the UI renders
objects, rows, revisions or server-side drafts. 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, Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
immutable import batches with original bytes plus the classified cell matrix, immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by 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 original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
planning overrides and internal project links in the shared transaction. It planning overrides and internal project links in the shared transaction. It
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
unchanged external state, and Redo restores the same UUIDs and bytes. No unchanged external state, and Redo restores the same UUIDs and bytes.
dedicated file-and-decisions apply endpoint or staging wizard invokes this
command yet.
`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless `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 read path for the first wizard stage. It rejects projects with an existing
external source and returns source-room groups, exact room-number suggestions, external source and returns source-room groups, exact room-number suggestions,
exact family/type groups, projected object values, issue counts and the current exact family/type groups, projected object values, issue counts and the current
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or 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 The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint `POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration 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 and exact family/type group, blocks unclassified families, creates all stable
external UUIDs server-side and invokes `external-import.apply-initial`. external UUIDs server-side and invokes `external-import.apply-initial`.
Room/default-board and optional ProjectDevice links are supported; CSV circuit Room/default-board and optional ProjectDevice links are supported; CSV circuit
values remain source-only and every `circuitDeviceRowId` remains null. The values remain source-only and every `circuitDeviceRowId` remains null. The UI
decision UI is still pending. 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 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.
+12 -2
View File
@@ -404,7 +404,17 @@ project device never triggers synchronization implicitly.
- returns transport metadata, SHA-256, row classifications, suspect row - returns transport metadata, SHA-256, row classifications, suspect row
numbers and mapped source values for every recognized IFC object numbers and mapped source values for every recognized IFC object
- does not persist a draft, mutate project state or create a revision - 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`, Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. A later `ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
confirmed import must upload and parse the file again and verify its hash. import uploads and parses the file again and verifies its hash.
@@ -9,7 +9,8 @@
- Named and automatic logical project snapshots can be listed and restored - Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained. 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. - 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 - The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model. versioning, permissions or controlled synchronization model.
+10 -6
View File
@@ -527,9 +527,8 @@ validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt.
Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand, CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
Redo verwendet dieselben UUIDs und Bytes. Ein aufrufender Import-Endpunkt und Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
der mehrstufige Wizard sind noch nicht implementiert; bloße Vorschau erzeugt erzeugen weiterhin keine dieser Zeilen.
weiterhin keine dieser Zeilen.
`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.
@@ -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 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 zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur
die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der 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 ## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung - Mehrbenutzerbetrieb und Konfliktauflösung
- bestätigter Revit-Erstimport und vollständiger CSV-/IFCGUID-Round-trip über - Folgeimport, Editor-Zuordnung und strukturtreuer Revit-Rückexport
die vorhandene Transport- und Persistenzgrundlage hinaus
- vollständige elektrische Dimensionierung - vollständige elektrische Dimensionierung
- Produktionsdeployment - Produktionsdeployment
+2 -2
View File
@@ -532,8 +532,8 @@ Acceptance criteria:
## Phase 14: External Model Round-Trip ## Phase 14: External Model Round-Trip
Status: Phase 14.0 (Audit und Verträge) ist dokumentiert und wartet vor der Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
Laufzeitimplementierung auf Freigabe. Siehe implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md) [Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
und die verbindlichen und die verbindlichen
[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md). [Revit-CSV-Anforderungen](revit-csv-integration-requirements.md).
@@ -288,19 +288,20 @@ einen Commit aufgenommen.
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
bleibt unverändert. bleibt unverändert.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard 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 Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge, exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
schreibt weder Entwurf noch Projektzustand. Entscheidungs-UI und Apply- schreibt weder Entwurf noch Projektzustand. Der dedizierte
Endpunkt stehen noch aus. **Backend-Apply erledigt:** Der dedizierte
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
Planung, vollständige Entscheidungen für alle Quellräume und exakten Planung, vollständige Entscheidungen für alle Quellräume und exakten
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben 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 Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
+286 -4
View File
@@ -6,9 +6,11 @@ import {
type ExternalCsvConfiguration, type ExternalCsvConfiguration,
type ExternalCsvFamilyTypeRule, type ExternalCsvFamilyTypeRule,
} from "../../external-model/csv/external-csv-contracts"; } from "../../external-model/csv/external-csv-contracts";
import type { ExternalCsvPreviewDto } from "../types"; import type { ExternalCsvPreviewDto, ExternalInitialImportPlanDto } from "../types";
import { import {
applyExternalInitialImport,
getExternalCsvConfiguration, getExternalCsvConfiguration,
planExternalInitialImport,
previewExternalCsv, previewExternalCsv,
updateExternalCsvConfiguration, updateExternalCsvConfiguration,
} from "../utils/api"; } from "../utils/api";
@@ -62,9 +64,16 @@ export function RevitCsvModal({
const [savedConfiguration, setSavedConfiguration] = useState<string | null>(null); const [savedConfiguration, setSavedConfiguration] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ExternalCsvPreviewDto | null>(null); const [preview, setPreview] = useState<ExternalCsvPreviewDto | null>(null);
const [importPlan, setImportPlan] = useState<ExternalInitialImportPlanDto | null>(null);
const [roomDecisions, setRoomDecisions] = useState<Record<string, { roomId: string | null; defaultDistributionBoardId: string | null }>>({});
const [familyLinks, setFamilyLinks] = useState<Record<string, string | null>>({});
const [sourceName, setSourceName] = useState("Revit-Gesamtmodell");
const [success, setSuccess] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false); const [isPreviewing, setIsPreviewing] = useState(false);
const [isPlanning, setIsPlanning] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -85,6 +94,8 @@ export function RevitCsvModal({
function replaceConfiguration(next: ExternalCsvConfiguration) { function replaceConfiguration(next: ExternalCsvConfiguration) {
setConfiguration(next); setConfiguration(next);
setPreview(null); setPreview(null);
setImportPlan(null);
setSuccess(null);
} }
async function handleSave(event: FormEvent<HTMLFormElement>) { async function handleSave(event: FormEvent<HTMLFormElement>) {
@@ -98,6 +109,7 @@ export function RevitCsvModal({
configuration configuration
); );
setSavedConfiguration(JSON.stringify(result.configuration.configuration)); setSavedConfiguration(JSON.stringify(result.configuration.configuration));
setImportPlan(null);
onRevisionChange(result.history.currentRevision); onRevisionChange(result.history.currentRevision);
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht gespeichert werden."); setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht gespeichert werden.");
@@ -113,6 +125,8 @@ export function RevitCsvModal({
try { try {
const contentBase64 = await fileToBase64(file); const contentBase64 = await fileToBase64(file);
setPreview(await previewExternalCsv(projectId, file.name, contentBase64)); setPreview(await previewExternalCsv(projectId, file.name, contentBase64));
setImportPlan(null);
setSuccess(null);
} catch (reason) { } catch (reason) {
setPreview(null); setPreview(null);
setError(reason instanceof Error ? reason.message : "CSV-Vorschau konnte nicht erstellt werden."); 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) { function updateColumn(key: (typeof columnFields)[number][0], value: string) {
setConfiguration((current) => ({ setConfiguration((current) => ({
...current, ...current,
columns: { ...current.columns, [key]: key === "quantity" && !value.trim() ? null : value }, columns: { ...current.columns, [key]: key === "quantity" && !value.trim() ? null : value },
})); }));
setPreview(null); setPreview(null);
setImportPlan(null);
} }
return ( return (
<FormModal <FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen." description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
dialogSize="viewport" dialogSize="viewport"
isSaving={isSaving} isSaving={isSaving || isApplying}
onClose={onClose} onClose={onClose}
onSubmit={handleSave} onSubmit={handleSave}
submitDisabled={isLoading || isPreviewing || !isDirty} submitDisabled={isLoading || isPreviewing || isPlanning || isApplying || !isDirty}
submitLabel="Konfiguration speichern" submitLabel="Konfiguration speichern"
title="Revit-CSV" title="Revit-CSV"
> >
{error ? <div className="alert alert-warning">{error}</div> : null} {error ? <div className="alert alert-warning">{error}</div> : null}
{success ? <div className="alert alert-success">{success}</div> : null}
{isLoading ? <p>Konfiguration wird geladen </p> : ( {isLoading ? <p>Konfiguration wird geladen </p> : (
<div className="d-grid gap-4"> <div className="d-grid gap-4">
<section> <section>
@@ -172,13 +253,27 @@ export function RevitCsvModal({
<h3 className="h6">CSV prüfen</h3> <h3 className="h6">CSV prüfen</h3>
<p className="small text-secondary">Die Vorschau speichert keine Datei und verändert keine Projektdaten.</p> <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"> <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" /> <input accept=".csv,text/csv" className="form-control" onChange={(event) => { setFile(event.target.files?.[0] ?? null); setPreview(null); setImportPlan(null); setSuccess(null); }} style={{ maxWidth: 480 }} type="file" />
<button className="btn btn-outline-primary" disabled={!file || isDirty || savedConfiguration === null || isPreviewing || isSaving} onClick={handlePreview} type="button">{isPreviewing ? "Wird geprüft …" : "Vorschau erstellen"}</button> <button className="btn btn-outline-primary" disabled={!file || isDirty || savedConfiguration === null || isPreviewing || isSaving} onClick={handlePreview} type="button">{isPreviewing ? "Wird geprüft …" : "Vorschau erstellen"}</button>
<button className="btn btn-primary" disabled={!preview || isDirty || isPlanning || isPreviewing || isSaving} onClick={handlePlanImport} type="button">{isPlanning ? "Wird geplant …" : "Erstimport planen"}</button>
</div> </div>
{isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null} {isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null}
</section> </section>
{preview ? <PreviewResult preview={preview} /> : null} {preview ? <PreviewResult preview={preview} /> : null}
{importPlan ? (
<RevitCsvImportPlan
familyLinks={familyLinks}
isApplying={isApplying}
onApply={handleApplyImport}
onFamilyLinkChange={(familyAndType, projectDeviceId) => setFamilyLinks((current) => ({ ...current, [familyAndType]: projectDeviceId }))}
onRoomDecisionChange={(sourceRoomKey, decision) => setRoomDecisions((current) => ({ ...current, [sourceRoomKey]: decision }))}
plan={importPlan}
roomDecisions={roomDecisions}
setSourceName={setSourceName}
sourceName={sourceName}
/>
) : null}
</div> </div>
)} )}
</FormModal> </FormModal>
@@ -204,6 +299,193 @@ function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) {
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>; return <section className="border-top pt-3"><h3 className="h6">Prüfergebnis</h3><div className="row g-2 mb-3"><Stat label="Objekte" value={preview.objectCount} /><Stat label="Passthrough-Zeilen" value={preview.passthroughCount} /><Stat label="Verdachtszeilen" value={preview.suspectObjectCount} /><Stat label="Kopfzeile" value={preview.headerRowNumber} /></div><p className="small text-secondary text-break mb-2">SHA-256: {preview.sha256}</p><div className="table-responsive"><table className="table table-sm"><thead><tr><th>Zeile</th><th>IFC-GUID</th><th>Raum</th><th>Familie und Typ</th><th>Auswahlkenner</th><th>Stromkreis</th></tr></thead><tbody>{visibleObjects.map((object) => <tr key={object.ifcGuid}><td>{object.rowNumber}</td><td><code>{object.ifcGuid}</code></td><td>{[object.roomNumber, object.roomName].filter(Boolean).join(" · ") || ""}</td><td>{object.familyAndType || ""}</td><td>{object.selectionMarker || ""}</td><td>{object.circuitIdentifier || ""}</td></tr>)}</tbody></table></div>{preview.objects.length > visibleObjects.length ? <p className="small text-secondary">Es werden die ersten {visibleObjects.length} von {preview.objects.length} Objekten angezeigt.</p> : null}</section>;
} }
interface RoomDecision {
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export function RevitCsvImportPlan({
familyLinks,
isApplying,
onApply,
onFamilyLinkChange,
onRoomDecisionChange,
plan,
roomDecisions,
setSourceName,
sourceName,
}: {
familyLinks: Record<string, string | null>;
isApplying: boolean;
onApply: () => void;
onFamilyLinkChange: (familyAndType: string, projectDeviceId: string | null) => void;
onRoomDecisionChange: (sourceRoomKey: string, decision: RoomDecision) => void;
plan: ExternalInitialImportPlanDto;
roomDecisions: Record<string, RoomDecision>;
setSourceName: (value: string) => void;
sourceName: string;
}) {
const hasBlockingIssues = plan.issueCounts.unknownFamilyAndType > 0;
const warningCount =
plan.issueCounts.invalidPower +
plan.issueCounts.invalidQuantity +
plan.issueCounts.missingRoom +
plan.suspectObjectCount;
const floorsById = new Map(plan.existing.floors.map((floor) => [floor.id, floor]));
return (
<section className="border-top pt-3">
<div className="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div>
<h3 className="h6 mb-1">Erstimport vorbereiten</h3>
<p className="small text-secondary mb-0">
Raum- und Gerätekatalog-Zuordnungen werden zusammen mit {plan.objectCount} Revit-Objekten gespeichert.
Stromkreise und Gerätezeilen entstehen dabei noch nicht.
</p>
</div>
<span className="badge text-bg-secondary">Konfiguration {plan.configurationVersion}</span>
</div>
<div className="row g-2 my-2">
<Stat label="Quellräume" value={plan.sourceRooms.length} />
<Stat label="Familien/Typen" value={plan.familyGroups.length} />
<Stat label="Objekte" value={plan.objectCount} />
<Stat label="Hinweise" value={warningCount} />
</div>
{hasBlockingIssues ? (
<div className="alert alert-danger">
{plan.issueCounts.unknownFamilyAndType} Objekte besitzen noch keine exakte Familie-und-Typ-Regel.
Ergänze die Regeln oben, speichere die Konfiguration und plane den Import erneut.
</div>
) : null}
{plan.issueCounts.invalidPower > 0 ? <div className="alert alert-warning py-2">Bei {plan.issueCounts.invalidPower} Objekten ist die Leistung ungültig.</div> : null}
{plan.issueCounts.invalidQuantity > 0 ? <div className="alert alert-warning py-2">Bei {plan.issueCounts.invalidQuantity} Objekten ist die Menge ungültig.</div> : null}
{plan.issueCounts.missingRoom > 0 ? <div className="alert alert-warning py-2">{plan.issueCounts.missingRoom} Objekte besitzen keine Raumangabe.</div> : null}
{plan.suspectObjectCount > 0 ? <div className="alert alert-warning py-2">{plan.suspectObjectCount} Objektzeilen wurden als verdächtig erkannt.</div> : null}
<label className="form-label mt-2" style={{ maxWidth: 520 }}>
<span>Bezeichnung des externen Modells</span>
<input
className="form-control"
onChange={(event) => setSourceName(event.target.value)}
value={sourceName}
/>
</label>
<h4 className="h6 mt-4">Räume zuordnen</h4>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead><tr><th>Quellraum</th><th>Objekte</th><th>Treffer</th><th>Interner Raum</th><th>Standardverteilung</th></tr></thead>
<tbody>
{plan.sourceRooms.map((sourceRoom) => {
const decision = roomDecisions[sourceRoom.sourceRoomKey] ?? {
roomId: sourceRoom.suggestedRoomId,
defaultDistributionBoardId: null,
};
return (
<tr key={sourceRoom.sourceRoomKey}>
<td>{[sourceRoom.roomNumber, sourceRoom.roomName].filter(Boolean).join(" · ")}</td>
<td>{sourceRoom.objectCount}</td>
<td><RoomMatchBadge status={sourceRoom.matchStatus} /></td>
<td>
<select
aria-label={`Interner Raum für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select form-select-sm"
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
...decision,
roomId: event.target.value || null,
})}
value={decision.roomId ?? ""}
>
<option value="">Nicht verknüpfen</option>
{plan.existing.rooms.map((room) => {
const floor = room.floorId ? floorsById.get(room.floorId) : null;
return <option key={room.id} value={room.id}>{room.roomNumber} · {room.roomName}{floor ? ` (${floor.name})` : ""}</option>;
})}
</select>
</td>
<td>
<select
aria-label={`Standardverteilung für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select form-select-sm"
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
...decision,
defaultDistributionBoardId: event.target.value || null,
})}
value={decision.defaultDistributionBoardId ?? ""}
>
<option value="">Keine Standardverteilung</option>
{plan.existing.distributionBoards.map((board) => <option key={board.id} value={board.id}>{board.name}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<h4 className="h6 mt-4">Familien und Typen zuordnen</h4>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead><tr><th>Familie und Typ</th><th>Objekte</th><th>Klassifizierung</th><th>ProjectDevice (optional)</th></tr></thead>
<tbody>
{plan.familyGroups.map((group) => (
<tr key={group.familyAndType}>
<td>{group.familyAndType || ""}</td>
<td>{group.objectCount}</td>
<td>
{group.classified
? `${group.internalDeviceType ?? ""} · ${categoryLabel(group.category)}`
: <span className="badge text-bg-danger">Regel fehlt</span>}
</td>
<td>
<select
aria-label={`ProjectDevice für ${group.familyAndType}`}
className="form-select form-select-sm"
onChange={(event) => onFamilyLinkChange(group.familyAndType, event.target.value || null)}
value={familyLinks[group.familyAndType] ?? ""}
>
<option value="">Nicht verknüpfen</option>
{plan.existing.projectDevices.map((device) => (
<option key={device.id} value={device.id}>{device.displayName || device.name} · {categoryLabel(device.category)}</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="d-flex justify-content-end mt-3">
<button
className="btn btn-primary"
disabled={hasBlockingIssues || isApplying || !sourceName.trim()}
onClick={onApply}
type="button"
>
{isApplying ? "Import wird übernommen …" : "Erstimport übernehmen"}
</button>
</div>
</section>
);
}
function RoomMatchBadge({ status }: { status: ExternalInitialImportPlanDto["sourceRooms"][number]["matchStatus"] }) {
if (status === "exact-number") return <span className="badge text-bg-success">Eindeutig</span>;
if (status === "ambiguous-number") return <span className="badge text-bg-warning">Mehrdeutig</span>;
return <span className="badge text-bg-secondary">Neu</span>;
}
function categoryLabel(category: string | null) {
if (category === "lighting") return "Beleuchtung";
if (category === "single_phase") return "1-phasig";
if (category === "three_phase") return "3-phasig";
return "Ohne Kategorie";
}
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 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) { function fileToBase64(file: File) {
+31
View File
@@ -14,6 +14,7 @@ import type {
ExternalCsvConfiguration, ExternalCsvConfiguration,
ExternalCsvDialect, ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts"; } from "../external-model/csv/external-csv-contracts";
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
export interface ExternalCsvConfigurationSnapshotDto { export interface ExternalCsvConfigurationSnapshotDto {
id: string; id: string;
@@ -51,6 +52,36 @@ export interface ExternalCsvPreviewDto {
suspectRowNumbers: number[]; 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 { export interface ProjectDto {
id: string; id: string;
name: string; name: string;
+27
View File
@@ -28,6 +28,9 @@ import type {
CircuitTreeResponseDto, CircuitTreeResponseDto,
ExternalCsvConfigurationSnapshotDto, ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto, ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
ApplyExternalInitialImportInput,
ExternalInitialImportResultDto,
} from "../types"; } from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; 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";
@@ -370,6 +373,30 @@ export function previewExternalCsv(
); );
} }
export function planExternalInitialImport(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<ExternalInitialImportPlanDto>(
`/api/projects/${projectId}/external-csv/initial-import/plan`,
{
method: "POST",
body: JSON.stringify({ fileName, contentBase64 }),
}
);
}
export function applyExternalInitialImport(
projectId: string,
input: ApplyExternalInitialImportInput
) {
return request<ExternalInitialImportResultDto>(
`/api/projects/${projectId}/external-csv/initial-import/apply`,
{ method: "POST", body: JSON.stringify(input) }
);
}
export function copyDistributionBoard( export function copyDistributionBoard(
projectId: string, projectId: string,
distributionBoardId: string, distributionBoardId: string,
+94 -2
View File
@@ -2,17 +2,23 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { createElement } from "react"; import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { RevitCsvModal } from "../src/frontend/components/revit-csv-modal.js";
import { import {
RevitCsvImportPlan,
RevitCsvModal,
} from "../src/frontend/components/revit-csv-modal.js";
import {
applyExternalInitialImport,
getExternalCsvConfiguration, getExternalCsvConfiguration,
planExternalInitialImport,
previewExternalCsv, previewExternalCsv,
updateExternalCsvConfiguration, updateExternalCsvConfiguration,
} from "../src/frontend/utils/api.js"; } 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 { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("Revit CSV frontend API", () => { 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 requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => { globalThis.fetch = async (input, init) => {
@@ -32,6 +38,17 @@ describe("Revit CSV frontend API", () => {
await getExternalCsvConfiguration("project-1"); await getExternalCsvConfiguration("project-1");
await updateExternalCsvConfiguration("project-1", 7, configuration); await updateExternalCsvConfiguration("project-1", 7, configuration);
await previewExternalCsv("project-1", "revit.csv", "YWJj"); 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 { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
@@ -52,6 +69,25 @@ describe("Revit CSV frontend API", () => {
method: "POST", method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" }, 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.match(markup, /max-width:calc\(100vw - 2rem\)/);
assert.doesNotMatch(markup, /modal-lg/); 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/);
});
}); });