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
project duplication preserve the configuration; duplication remaps its internal
id and project link.
The project page exposes the current Phase 14.1 Revit CSV modal. It edits the
complete transport/column/family-rule configuration through the revision-safe
endpoint and requests a stateless preview. Preview responses include all mapped
objects, while the UI renders only the first 25. No preview creates external
objects, rows, revisions or server-side drafts.
The project page exposes the Revit CSV modal. It edits the complete
transport/column/family-rule configuration through the revision-safe endpoint,
requests a stateless preview and then shows the Phase 14.2 initial-import
decisions. Preview responses include all mapped objects, while the UI renders
only the first 25. No preview or plan creates external objects, rows, revisions
or server-side drafts.
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by
@@ -287,15 +288,14 @@ Confirmed initial state is written only through
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
planning overrides and internal project links in the shared transaction. It
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
unchanged external state, and Redo restores the same UUIDs and bytes. No
dedicated file-and-decisions apply endpoint or staging wizard invokes this
command yet.
unchanged external state, and Redo restores the same UUIDs and bytes.
`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless
read path for the first wizard stage. It rejects projects with an existing
external source and returns source-room groups, exact room-number suggestions,
exact family/type groups, projected object values, issue counts and the current
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or
domain row; the decision UI is still pending.
domain row. The wide modal renders these groups and catalogs as explicit
decisions and identifies exact, ambiguous and new room matches.
The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration
@@ -303,8 +303,9 @@ version and project revision. It requires one explicit decision per source-room
and exact family/type group, blocks unclassified families, creates all stable
external UUIDs server-side and invokes `external-import.apply-initial`.
Room/default-board and optional ProjectDevice links are supported; CSV circuit
values remain source-only and every `circuitDeviceRowId` remains null. The
decision UI is still pending.
values remain source-only and every `circuitDeviceRowId` remains null. The UI
blocks unclassified families, requires a final confirmation and applies the
whole import as one persistent Undo/Redo step.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
+12 -2
View File
@@ -404,7 +404,17 @@ project device never triggers synchronization implicitly.
- returns transport metadata, SHA-256, row classifications, suspect row
numbers and mapped source values for every recognized IFC object
- does not persist a draft, mutate project state or create a revision
- `POST /projects/:projectId/external-csv/initial-import/plan`
- reparses the file and returns grouped room/family decisions plus current
room, distribution-board and ProjectDevice catalogs
- is stateless and rejects an already imported external source
- `POST /projects/:projectId/external-csv/initial-import/apply`
- requires the expected revision, configuration version, file hash and one
decision for every room and exact family/type group
- reparses the file, blocks unknown family/type values and executes
`external-import.apply-initial` as one undoable project revision
- creates no Circuit or CircuitDeviceRow
Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. A later
confirmed import must upload and parse the file again and verify its hash.
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
import uploads and parses the file again and verifies its hash.
@@ -9,7 +9,8 @@
- Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- The Revit/CSV foundation and confirmed initial import are implemented. Row
assignment, follow-up import, conflict review and return export are pending.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model.
+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
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
Redo verwendet dieselben UUIDs und Bytes. Ein aufrufender Import-Endpunkt und
der mehrstufige Wizard sind noch nicht implementiert; bloße Vorschau erzeugt
weiterhin keine dieser Zeilen.
Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
erzeugen weiterhin keine dieser Zeilen.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -560,13 +559,18 @@ Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur
die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der
Response verfügbar, ohne alle Zeilen in den DOM einzufügen.
Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende
Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er
zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume,
Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die
Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog
wird der vollständige Import als eine persistente, rückgängig machbare
Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen.
## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung
- bestätigter Revit-Erstimport und vollständiger CSV-/IFCGUID-Round-trip über
die vorhandene Transport- und Persistenzgrundlage hinaus
- Folgeimport, Editor-Zuordnung und strukturtreuer Revit-Rückexport
- vollständige elektrische Dimensionierung
- Produktionsdeployment
+2 -2
View File
@@ -532,8 +532,8 @@ Acceptance criteria:
## Phase 14: External Model Round-Trip
Status: Phase 14.0 (Audit und Verträge) ist dokumentiert und wartet vor der
Laufzeitimplementierung auf Freigabe. Siehe
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
und die verbindlichen
[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md).
@@ -288,19 +288,20 @@ einen Commit aufgenommen.
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
bleibt unverändert.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Begonnen:**
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:**
Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
schreibt weder Entwurf noch Projektzustand. Entscheidungs-UI und Apply-
Endpunkt stehen noch aus. **Backend-Apply erledigt:** Der dedizierte
schreibt weder Entwurf noch Projektzustand. Der dedizierte
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
Planung, vollständige Entscheidungen für alle Quellräume und exakten
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
ausgeschlossen. Die Entscheidungs-UI steht noch aus.
ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen,
bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung.
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
+286 -4
View File
@@ -6,9 +6,11 @@ import {
type ExternalCsvConfiguration,
type ExternalCsvFamilyTypeRule,
} from "../../external-model/csv/external-csv-contracts";
import type { ExternalCsvPreviewDto } from "../types";
import type { ExternalCsvPreviewDto, ExternalInitialImportPlanDto } from "../types";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
planExternalInitialImport,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../utils/api";
@@ -62,9 +64,16 @@ export function RevitCsvModal({
const [savedConfiguration, setSavedConfiguration] = useState<string | null>(null);
const [file, setFile] = useState<File | 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 [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isPlanning, setIsPlanning] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -85,6 +94,8 @@ export function RevitCsvModal({
function replaceConfiguration(next: ExternalCsvConfiguration) {
setConfiguration(next);
setPreview(null);
setImportPlan(null);
setSuccess(null);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
@@ -98,6 +109,7 @@ export function RevitCsvModal({
configuration
);
setSavedConfiguration(JSON.stringify(result.configuration.configuration));
setImportPlan(null);
onRevisionChange(result.history.currentRevision);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht gespeichert werden.");
@@ -113,6 +125,8 @@ export function RevitCsvModal({
try {
const contentBase64 = await fileToBase64(file);
setPreview(await previewExternalCsv(projectId, file.name, contentBase64));
setImportPlan(null);
setSuccess(null);
} catch (reason) {
setPreview(null);
setError(reason instanceof Error ? reason.message : "CSV-Vorschau konnte nicht erstellt werden.");
@@ -121,26 +135,93 @@ export function RevitCsvModal({
}
}
async function handlePlanImport() {
if (!file || !preview || isDirty || savedConfiguration === null) return;
setIsPlanning(true);
setError(null);
try {
const contentBase64 = await fileToBase64(file);
const plan = await planExternalInitialImport(projectId, file.name, contentBase64);
if (plan.sha256 !== preview.sha256) {
throw new Error("Die Datei stimmt nicht mehr mit der Vorschau überein.");
}
setImportPlan(plan);
setRoomDecisions(Object.fromEntries(plan.sourceRooms.map((room) => [
room.sourceRoomKey,
{ roomId: room.suggestedRoomId, defaultDistributionBoardId: null },
])));
setFamilyLinks(Object.fromEntries(plan.familyGroups.map((group) => [
group.familyAndType,
null,
])));
} catch (reason) {
setImportPlan(null);
setError(reason instanceof Error ? reason.message : "Erstimport konnte nicht geplant werden.");
} finally {
setIsPlanning(false);
}
}
async function handleApplyImport() {
if (!file || !importPlan) return;
if (!window.confirm(
`${importPlan.objectCount} Revit-Objekte jetzt als einen rückgängig machbaren Import übernehmen?`
)) return;
setIsApplying(true);
setError(null);
try {
const contentBase64 = await fileToBase64(file);
const result = await applyExternalInitialImport(projectId, {
expectedRevision: currentRevision,
expectedConfigurationVersion: importPlan.configurationVersion,
expectedSha256: importPlan.sha256,
fileName: file.name,
contentBase64,
sourceName,
roomDecisions: importPlan.sourceRooms.map((room) => ({
sourceRoomKey: room.sourceRoomKey,
roomId: roomDecisions[room.sourceRoomKey]?.roomId ?? null,
defaultDistributionBoardId:
roomDecisions[room.sourceRoomKey]?.defaultDistributionBoardId ?? null,
})),
familyProjectDeviceDecisions: importPlan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: familyLinks[group.familyAndType] ?? null,
})),
});
onRevisionChange(result.history.currentRevision);
setSuccess(`${result.import.objectCount} Revit-Objekte wurden übernommen.`);
setImportPlan(null);
setPreview(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Erstimport konnte nicht übernommen werden.");
} finally {
setIsApplying(false);
}
}
function updateColumn(key: (typeof columnFields)[number][0], value: string) {
setConfiguration((current) => ({
...current,
columns: { ...current.columns, [key]: key === "quantity" && !value.trim() ? null : value },
}));
setPreview(null);
setImportPlan(null);
}
return (
<FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
dialogSize="viewport"
isSaving={isSaving}
isSaving={isSaving || isApplying}
onClose={onClose}
onSubmit={handleSave}
submitDisabled={isLoading || isPreviewing || !isDirty}
submitDisabled={isLoading || isPreviewing || isPlanning || isApplying || !isDirty}
submitLabel="Konfiguration speichern"
title="Revit-CSV"
>
{error ? <div className="alert alert-warning">{error}</div> : null}
{success ? <div className="alert alert-success">{success}</div> : null}
{isLoading ? <p>Konfiguration wird geladen </p> : (
<div className="d-grid gap-4">
<section>
@@ -172,13 +253,27 @@ export function RevitCsvModal({
<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" />
<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-primary" disabled={!preview || isDirty || isPlanning || isPreviewing || isSaving} onClick={handlePlanImport} type="button">{isPlanning ? "Wird geplant …" : "Erstimport planen"}</button>
</div>
{isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null}
</section>
{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>
)}
</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>;
}
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 fileToBase64(file: File) {
+31
View File
@@ -14,6 +14,7 @@ import type {
ExternalCsvConfiguration,
ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts";
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
export interface ExternalCsvConfigurationSnapshotDto {
id: string;
@@ -51,6 +52,36 @@ export interface ExternalCsvPreviewDto {
suspectRowNumbers: number[];
}
export type ExternalInitialImportPlanDto = ReturnType<
typeof createExternalInitialImportPlan
>;
export interface ApplyExternalInitialImportInput {
expectedRevision: number;
expectedConfigurationVersion: number;
expectedSha256: string;
fileName: string;
contentBase64: string;
sourceName: string;
roomDecisions: Array<{
sourceRoomKey: string;
roomId: string | null;
defaultDistributionBoardId: string | null;
}>;
familyProjectDeviceDecisions: Array<{
familyAndType: string;
projectDeviceId: string | null;
}>;
}
export interface ExternalInitialImportResultDto extends ProjectCommandResultDto {
import: {
sourceId: string;
importBatchId: string;
objectCount: number;
};
}
export interface ProjectDto {
id: string;
name: string;
+27
View File
@@ -28,6 +28,9 @@ import type {
CircuitTreeResponseDto,
ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
ApplyExternalInitialImportInput,
ExternalInitialImportResultDto,
} from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
@@ -370,6 +373,30 @@ export function previewExternalCsv(
);
}
export function planExternalInitialImport(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<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(
projectId: string,
distributionBoardId: string,
+94 -2
View File
@@ -2,17 +2,23 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RevitCsvModal } from "../src/frontend/components/revit-csv-modal.js";
import {
RevitCsvImportPlan,
RevitCsvModal,
} from "../src/frontend/components/revit-csv-modal.js";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
planExternalInitialImport,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../src/frontend/utils/api.js";
import type { ExternalInitialImportPlanDto } from "../src/frontend/types.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("Revit CSV frontend API", () => {
it("uses the project-scoped configuration and preview endpoints", async () => {
it("uses the project-scoped configuration, preview and import endpoints", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
@@ -32,6 +38,17 @@ describe("Revit CSV frontend API", () => {
await getExternalCsvConfiguration("project-1");
await updateExternalCsvConfiguration("project-1", 7, configuration);
await previewExternalCsv("project-1", "revit.csv", "YWJj");
await planExternalInitialImport("project-1", "revit.csv", "YWJj");
await applyExternalInitialImport("project-1", {
expectedRevision: 8,
expectedConfigurationVersion: 2,
expectedSha256: "sha",
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
});
} finally {
globalThis.fetch = originalFetch;
}
@@ -52,6 +69,25 @@ describe("Revit CSV frontend API", () => {
method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" },
},
{
url: "/api/projects/project-1/external-csv/initial-import/plan",
method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" },
},
{
url: "/api/projects/project-1/external-csv/initial-import/apply",
method: "POST",
body: {
expectedRevision: 8,
expectedConfigurationVersion: 2,
expectedSha256: "sha",
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
},
},
]);
});
});
@@ -70,4 +106,60 @@ describe("Revit CSV modal presentation", () => {
assert.match(markup, /max-width:calc\(100vw - 2rem\)/);
assert.doesNotMatch(markup, /modal-lg/);
});
it("renders the initial import decisions without individual object rows", () => {
const plan: ExternalInitialImportPlanDto = {
configurationVersion: 2,
fileName: "revit.csv",
sha256: "sha",
byteLength: 100,
rowCount: 2,
objectCount: 1,
passthroughCount: 1,
suspectObjectCount: 0,
suspectRowNumbers: [],
sourceRooms: [{
sourceRoomKey: "101|büro",
roomNumber: "101",
roomName: "Büro",
objectCount: 1,
suggestedRoomId: "room-1",
matchStatus: "exact-number",
candidateRoomIds: ["room-1"],
}],
familyGroups: [{
familyAndType: "Steckdose : Standard",
objectCount: 1,
classified: true,
category: "single_phase",
connectionKind: null,
internalDeviceType: "socket",
}],
issueCounts: { unknownFamilyAndType: 0, invalidPower: 0, invalidQuantity: 0, missingRoom: 0 },
objects: [],
existing: {
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }],
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
},
};
const markup = renderToStaticMarkup(createElement(RevitCsvImportPlan, {
familyLinks: { "Steckdose : Standard": "device-1" },
isApplying: false,
onApply: () => undefined,
onFamilyLinkChange: () => undefined,
onRoomDecisionChange: () => undefined,
plan,
roomDecisions: { "101|büro": { roomId: "room-1", defaultDistributionBoardId: "board-1" } },
setSourceName: () => undefined,
sourceName: "Revit-Gesamtmodell",
}));
assert.match(markup, /Erstimport vorbereiten/);
assert.match(markup, /101 · Büro/);
assert.match(markup, /Steckdose : Standard/);
assert.match(markup, /Erstimport übernehmen/);
assert.doesNotMatch(markup, /IFC-GUID/);
});
});