Add Revit initial import wizard

This commit is contained in:
Julian Appel 2026-08-02 17:52:21 +02:00
parent 8646f346e6
commit ab2449419b
10 changed files with 481 additions and 32 deletions

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) {

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;

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,