Add Revit catalog creation flow

This commit is contained in:
Julian Appel 2026-08-02 18:08:18 +02:00
parent ab2449419b
commit bc2bc7ece5
8 changed files with 435 additions and 133 deletions

View file

@ -35,6 +35,7 @@ import {
} from "../../../frontend/utils/api";
import type {
CircuitListDto,
CreateRoomInput,
CreateProjectDeviceInput,
DistributionBoardDto,
FloorDto,
@ -60,6 +61,7 @@ import {
import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
import { RoomModal } from "../../../frontend/components/room-modal";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@ -101,9 +103,6 @@ export default function ProjectDetailPage() {
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [floorName, setFloorName] = useState("");
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState("");
const [roomFloorId, setRoomFloorId] = useState("");
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
@ -285,24 +284,11 @@ export default function ProjectDetailPage() {
}
}
async function handleSaveRoom(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (
!projectId ||
!project ||
!roomNumber.trim() ||
!roomName.trim()
) {
return;
}
async function handleSaveRoom(input: CreateRoomInput) {
if (!projectId || !project) return;
setIsSaving(true);
setError(null);
try {
const input = {
floorId: roomFloorId || undefined,
roomNumber: roomNumber.trim(),
roomName: roomName.trim(),
};
const result = editingRoom
? await updateRoom(
projectId,
@ -319,9 +305,6 @@ export default function ProjectDetailPage() {
: [...current, result.room]
);
applyProjectRevision(result.history.currentRevision);
setRoomNumber("");
setRoomName("");
setRoomFloorId("");
setEditingRoom(null);
setStructureModal(null);
} catch (err) {
@ -500,6 +483,22 @@ export default function ProjectDetailPage() {
}
}
async function handleCreateRevitRoom(input: CreateRoomInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createRoom(projectId, input, project.currentRevision);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createProjectDevice(projectId, input, project.currentRevision);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
return result;
}
function openCreateFloor() {
setEditingFloor(null);
setFloorName("");
@ -514,17 +513,11 @@ export default function ProjectDetailPage() {
function openCreateRoom() {
setEditingRoom(null);
setRoomNumber("");
setRoomName("");
setRoomFloorId("");
setStructureModal("room");
}
function openEditRoom(room: RoomDto) {
setEditingRoom(room);
setRoomNumber(room.roomNumber);
setRoomName(room.roomName);
setRoomFloorId(room.floorId ?? "");
setStructureModal("room");
}
@ -557,9 +550,6 @@ export default function ProjectDetailPage() {
setRooms((current) => current.filter((room) => room.id !== editingRoom.id));
applyProjectRevision(result.history.currentRevision);
setEditingRoom(null);
setRoomNumber("");
setRoomName("");
setRoomFloorId("");
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden.");
@ -1585,73 +1575,17 @@ export default function ProjectDetailPage() {
) : null}
{structureModal === "room" ? (
<FormModal
<RoomModal
floors={floors}
initialRoom={editingRoom ?? undefined}
isSaving={isSaving}
onClose={() => {
setStructureModal(null);
setEditingRoom(null);
}}
onSubmit={handleSaveRoom}
submitDisabled={!roomNumber.trim() || !roomName.trim()}
submitLabel={editingRoom ? "Änderungen speichern" : "Raum anlegen"}
title={editingRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="room-number">
Raumnummer
</label>
<input
autoFocus
className="form-control"
id="room-number"
onChange={(event) => setRoomNumber(event.target.value)}
required
value={roomNumber}
/>
</div>
<div className="col-12 col-md-8">
<label className="form-label" htmlFor="room-name">
Raumname
</label>
<input
className="form-control"
id="room-name"
onChange={(event) => setRoomName(event.target.value)}
required
value={roomName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="room-floor">
Etage
</label>
<select
className="form-select"
id="room-floor"
onChange={(event) => setRoomFloorId(event.target.value)}
value={roomFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
</div>
{editingRoom ? (
<div className="border-top mt-4 pt-3">
<p className="text-secondary small mb-2">
In Stromkreisen verwendete Räume können nicht gelöscht werden.
</p>
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteRoom()} type="button">
Raum löschen
</button>
</div>
) : null}
</FormModal>
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
onSave={handleSaveRoom}
/>
) : null}
{isProjectDeviceModalOpen ? (
@ -1692,6 +1626,8 @@ export default function ProjectDetailPage() {
<RevitCsvModal
currentRevision={project.currentRevision}
onClose={() => setIsRevitCsvOpen(false)}
onCreateProjectDevice={handleCreateRevitProjectDevice}
onCreateRoom={handleCreateRevitRoom}
onRevisionChange={applyProjectRevision}
projectId={projectId}
/>

View file

@ -16,6 +16,7 @@ import {
interface ProjectDeviceModalProps {
globalDevices: GlobalDeviceDto[];
initialDevice?: ProjectDeviceDto;
initialValues?: Partial<CreateProjectDeviceInput>;
isSaving: boolean;
onClose: () => void;
onImportGlobal: (globalDeviceId: string) => Promise<void>;
@ -25,12 +26,13 @@ interface ProjectDeviceModalProps {
export function ProjectDeviceModal({
globalDevices,
initialDevice,
initialValues,
isSaving,
onClose,
onImportGlobal,
onSave,
}: ProjectDeviceModalProps) {
const [values, setValues] = useState(() => toFormValues(initialDevice));
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
function update(key: keyof typeof values, value: string) {
@ -254,18 +256,21 @@ function NumberField({
);
}
function toFormValues(device?: ProjectDeviceDto) {
function toFormValues(
device?: ProjectDeviceDto,
initialValues?: Partial<CreateProjectDeviceInput>
) {
return {
name: device?.name ?? "",
displayName: device?.displayName ?? "",
connectionKind: device?.connectionKind ?? "",
costGroup: device?.costGroup ?? "",
category: device?.category ?? "single_phase",
quantity: String(device?.quantity ?? 1),
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
cosPhi: String(device?.cosPhi ?? 1),
remark: device?.remark ?? "",
name: device?.name ?? initialValues?.name ?? "",
displayName: device?.displayName ?? initialValues?.displayName ?? "",
connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "",
costGroup: device?.costGroup ?? initialValues?.costGroup ?? "",
category: device?.category ?? initialValues?.category ?? "single_phase",
quantity: String(device?.quantity ?? initialValues?.quantity ?? 1),
powerPerUnit: String(device?.powerPerUnit ?? initialValues?.powerPerUnit ?? 0.1),
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
remark: device?.remark ?? initialValues?.remark ?? "",
};
}

View file

@ -6,7 +6,14 @@ import {
type ExternalCsvConfiguration,
type ExternalCsvFamilyTypeRule,
} from "../../external-model/csv/external-csv-contracts";
import type { ExternalCsvPreviewDto, ExternalInitialImportPlanDto } from "../types";
import type {
CreateProjectDeviceInput,
CreateRoomInput,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
ProjectDeviceCommandResultDto,
ProjectRoomCommandResultDto,
} from "../types";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
@ -15,10 +22,16 @@ import {
updateExternalCsvConfiguration,
} from "../utils/api";
import { FormModal } from "./form-modal";
import { ProjectDeviceModal } from "./project-device-modal";
import { RoomModal } from "./room-modal";
interface RevitCsvModalProps {
currentRevision: number;
onClose: () => void;
onCreateProjectDevice: (
input: CreateProjectDeviceInput
) => Promise<ProjectDeviceCommandResultDto>;
onCreateRoom: (input: CreateRoomInput) => Promise<ProjectRoomCommandResultDto>;
onRevisionChange: (currentRevision: number) => void;
projectId: string;
}
@ -55,6 +68,8 @@ const columnFields = [
export function RevitCsvModal({
currentRevision,
onClose,
onCreateProjectDevice,
onCreateRoom,
onRevisionChange,
projectId,
}: RevitCsvModalProps) {
@ -74,6 +89,9 @@ export function RevitCsvModal({
const [isPreviewing, setIsPreviewing] = useState(false);
const [isPlanning, setIsPlanning] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [isCreatingCatalogEntry, setIsCreatingCatalogEntry] = useState(false);
const [newRoomSourceKey, setNewRoomSourceKey] = useState<string | null>(null);
const [newProjectDeviceFamily, setNewProjectDeviceFamily] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@ -209,6 +227,93 @@ export function RevitCsvModal({
setImportPlan(null);
}
async function handleCreateRoom(input: CreateRoomInput) {
if (!newRoomSourceKey) return;
setIsCreatingCatalogEntry(true);
setError(null);
try {
const result = await onCreateRoom(input);
setImportPlan((current) => current ? {
...current,
existing: {
...current.existing,
rooms: [...current.existing.rooms, result.room],
},
} : current);
setRoomDecisions((current) => ({
...current,
[newRoomSourceKey]: {
roomId: result.room.id,
defaultDistributionBoardId:
current[newRoomSourceKey]?.defaultDistributionBoardId ?? null,
},
}));
setNewRoomSourceKey(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Raum konnte nicht angelegt werden.");
setNewRoomSourceKey(null);
} finally {
setIsCreatingCatalogEntry(false);
}
}
async function handleCreateProjectDevice(input: CreateProjectDeviceInput) {
if (!newProjectDeviceFamily) return;
setIsCreatingCatalogEntry(true);
setError(null);
try {
const result = await onCreateProjectDevice(input);
setImportPlan((current) => current ? {
...current,
existing: {
...current.existing,
projectDevices: [...current.existing.projectDevices, result.projectDevice],
},
} : current);
setFamilyLinks((current) => ({
...current,
[newProjectDeviceFamily]: result.projectDevice.id,
}));
setNewProjectDeviceFamily(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "ProjectDevice konnte nicht angelegt werden.");
setNewProjectDeviceFamily(null);
} finally {
setIsCreatingCatalogEntry(false);
}
}
if (newRoomSourceKey && importPlan) {
const sourceRoom = importPlan.sourceRooms.find((room) => room.sourceRoomKey === newRoomSourceKey);
if (sourceRoom) {
return (
<RoomModal
floors={importPlan.existing.floors.map((floor) => ({ ...floor, projectId }))}
initialValues={{ roomNumber: sourceRoom.roomNumber, roomName: sourceRoom.roomName }}
isSaving={isCreatingCatalogEntry}
onClose={() => setNewRoomSourceKey(null)}
onSave={handleCreateRoom}
/>
);
}
}
if (newProjectDeviceFamily && importPlan) {
const group = importPlan.familyGroups.find((entry) => entry.familyAndType === newProjectDeviceFamily);
if (group) {
return (
<ProjectDeviceModal
globalDevices={[]}
initialValues={createRevitProjectDevicePrefill(importPlan, group.familyAndType)}
isSaving={isCreatingCatalogEntry}
onClose={() => setNewProjectDeviceFamily(null)}
onImportGlobal={async () => undefined}
onSave={handleCreateProjectDevice}
/>
);
}
}
return (
<FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
@ -266,6 +371,8 @@ export function RevitCsvModal({
familyLinks={familyLinks}
isApplying={isApplying}
onApply={handleApplyImport}
onCreateProjectDevice={setNewProjectDeviceFamily}
onCreateRoom={setNewRoomSourceKey}
onFamilyLinkChange={(familyAndType, projectDeviceId) => setFamilyLinks((current) => ({ ...current, [familyAndType]: projectDeviceId }))}
onRoomDecisionChange={(sourceRoomKey, decision) => setRoomDecisions((current) => ({ ...current, [sourceRoomKey]: decision }))}
plan={importPlan}
@ -308,6 +415,8 @@ export function RevitCsvImportPlan({
familyLinks,
isApplying,
onApply,
onCreateProjectDevice,
onCreateRoom,
onFamilyLinkChange,
onRoomDecisionChange,
plan,
@ -318,6 +427,8 @@ export function RevitCsvImportPlan({
familyLinks: Record<string, string | null>;
isApplying: boolean;
onApply: () => void;
onCreateProjectDevice: (familyAndType: string) => void;
onCreateRoom: (sourceRoomKey: string) => void;
onFamilyLinkChange: (familyAndType: string, projectDeviceId: string | null) => void;
onRoomDecisionChange: (sourceRoomKey: string, decision: RoomDecision) => void;
plan: ExternalInitialImportPlanDto;
@ -389,21 +500,31 @@ export function RevitCsvImportPlan({
<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>
<div className="input-group input-group-sm">
<select
aria-label={`Interner Raum für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select"
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>
<button
aria-label={`Raum ${sourceRoom.roomNumber || sourceRoom.roomName} neu anlegen`}
className="btn btn-outline-secondary"
onClick={() => onCreateRoom(sourceRoom.sourceRoomKey)}
type="button"
>
Neu
</button>
</div>
</td>
<td>
<select
@ -441,17 +562,28 @@ export function RevitCsvImportPlan({
: <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>
<div className="input-group input-group-sm">
<select
aria-label={`ProjectDevice für ${group.familyAndType}`}
className="form-select"
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>
<button
aria-label={`ProjectDevice für ${group.familyAndType} neu anlegen`}
className="btn btn-outline-secondary"
disabled={!group.classified}
onClick={() => onCreateProjectDevice(group.familyAndType)}
type="button"
>
Neu
</button>
</div>
</td>
</tr>
))}
@ -486,6 +618,37 @@ function categoryLabel(category: string | null) {
return "Ohne Kategorie";
}
export function createRevitProjectDevicePrefill(
plan: ExternalInitialImportPlanDto,
familyAndType: string
): Partial<CreateProjectDeviceInput> {
const group = plan.familyGroups.find((entry) => entry.familyAndType === familyAndType);
const firstObject = plan.objects.find(
(object) => object.sourceValues.familyAndType === familyAndType
);
const category = group?.category;
return {
name: group?.internalDeviceType || familyAndType || "Revit-Gerät",
displayName: firstObject?.suggestedPlanningValues.displayName || familyAndType,
connectionKind: group?.connectionKind ?? undefined,
category:
category === "lighting" || category === "three_phase"
? category
: "single_phase",
quantity: firstObject?.suggestedPlanningValues.effectiveQuantity ?? 1,
powerPerUnit:
firstObject?.suggestedPlanningValues.powerPerUnitW === null ||
firstObject?.suggestedPlanningValues.powerPerUnitW === undefined
? 0.1
: firstObject.suggestedPlanningValues.powerPerUnitW / 1000,
simultaneityFactor:
firstObject?.suggestedPlanningValues.simultaneityFactor ?? 1,
cosPhi: firstObject?.suggestedPlanningValues.cosPhi ?? undefined,
costGroup: firstObject?.suggestedPlanningValues.costGroup ?? undefined,
remark: firstObject?.suggestedPlanningValues.remark ?? undefined,
};
}
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

@ -0,0 +1,107 @@
"use client";
import React, { type FormEvent, useState } from "react";
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
import { FormModal } from "./form-modal";
interface RoomModalProps {
floors: FloorDto[];
initialRoom?: RoomDto;
initialValues?: Partial<CreateRoomInput>;
isSaving: boolean;
onClose: () => void;
onDelete?: () => void;
onSave: (input: CreateRoomInput) => Promise<void>;
}
export function RoomModal({
floors,
initialRoom,
initialValues,
isSaving,
onClose,
onDelete,
onSave,
}: RoomModalProps) {
const [roomNumber, setRoomNumber] = useState(
initialRoom?.roomNumber ?? initialValues?.roomNumber ?? ""
);
const [roomName, setRoomName] = useState(
initialRoom?.roomName ?? initialValues?.roomName ?? ""
);
const [floorId, setFloorId] = useState(
initialRoom?.floorId ?? initialValues?.floorId ?? ""
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!roomNumber.trim() || !roomName.trim()) return;
await onSave({
floorId: floorId || undefined,
roomNumber: roomNumber.trim(),
roomName: roomName.trim(),
});
}
return (
<FormModal
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!roomNumber.trim() || !roomName.trim()}
submitLabel={initialRoom ? "Änderungen speichern" : "Raum anlegen"}
title={initialRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="room-number">Raumnummer</label>
<input
autoFocus
className="form-control"
id="room-number"
onChange={(event) => setRoomNumber(event.target.value)}
required
value={roomNumber}
/>
</div>
<div className="col-12 col-md-8">
<label className="form-label" htmlFor="room-name">Raumname</label>
<input
className="form-control"
id="room-name"
onChange={(event) => setRoomName(event.target.value)}
required
value={roomName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="room-floor">Etage</label>
<select
className="form-select"
id="room-floor"
onChange={(event) => setFloorId(event.target.value)}
value={floorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => <option key={floor.id} value={floor.id}>{floor.name}</option>)}
</select>
</div>
</div>
{initialRoom && onDelete ? (
<div className="border-top mt-4 pt-3">
<p className="text-secondary small mb-2">
In Stromkreisen verwendete Räume können nicht gelöscht werden.
</p>
<button
className="btn btn-outline-danger"
disabled={isSaving}
onClick={onDelete}
type="button"
>
Raum löschen
</button>
</div>
) : null}
</FormModal>
);
}