Add Revit initial import wizard
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user