Add Revit catalog creation flow

This commit is contained in:
2026-08-02 18:08:18 +02:00
parent ab2449419b
commit bc2bc7ece5
8 changed files with 435 additions and 133 deletions
+4 -1
View File
@@ -295,7 +295,10 @@ 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 wide modal renders these groups and catalogs as explicit
decisions and identifies exact, ambiguous and new room matches.
decisions and identifies exact, ambiguous and new room matches. Missing rooms
and ProjectDevices are created through the reused project forms with mapped
Revit values as visible defaults. Each catalog creation remains its own project
command; the wizard adds and selects the result without discarding its plan.
The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration
+6
View File
@@ -566,6 +566,12 @@ 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.
Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über
die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese
Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt.
Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
ihn für die betroffene Gruppe.
## Noch nicht unterstützt
@@ -301,7 +301,10 @@ einen Commit aufgenommen.
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
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.
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende
Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten
Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese
Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen.
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
+28 -92
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}
/>
@@ -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 ?? "",
};
}
+190 -27
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) {
+107
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>
);
}
+79
View File
@@ -3,9 +3,12 @@ import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
createRevitProjectDevicePrefill,
RevitCsvImportPlan,
RevitCsvModal,
} from "../src/frontend/components/revit-csv-modal.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import { RoomModal } from "../src/frontend/components/room-modal.js";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
@@ -98,6 +101,8 @@ describe("Revit CSV modal presentation", () => {
createElement(RevitCsvModal, {
currentRevision: 0,
onClose: () => undefined,
onCreateProjectDevice: async () => ({} as never),
onCreateRoom: async () => ({} as never),
onRevisionChange: () => undefined,
projectId: "project-1",
})
@@ -148,6 +153,8 @@ describe("Revit CSV modal presentation", () => {
familyLinks: { "Steckdose : Standard": "device-1" },
isApplying: false,
onApply: () => undefined,
onCreateProjectDevice: () => undefined,
onCreateRoom: () => undefined,
onFamilyLinkChange: () => undefined,
onRoomDecisionChange: () => undefined,
plan,
@@ -160,6 +167,78 @@ describe("Revit CSV modal presentation", () => {
assert.match(markup, /101 · Büro/);
assert.match(markup, /Steckdose : Standard/);
assert.match(markup, /Erstimport übernehmen/);
assert.match(markup, /Raum 101 neu anlegen/);
assert.match(markup, /ProjectDevice für Steckdose : Standard neu anlegen/);
assert.doesNotMatch(markup, /IFC-GUID/);
});
it("prefills the existing room and ProjectDevice forms from Revit decisions", () => {
const roomMarkup = renderToStaticMarkup(createElement(RoomModal, {
floors: [{ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 0 }],
initialValues: { roomNumber: "101", roomName: "Büro" },
isSaving: false,
onClose: () => undefined,
onSave: async () => undefined,
}));
const deviceMarkup = renderToStaticMarkup(createElement(ProjectDeviceModal, {
globalDevices: [],
initialValues: {
name: "socket",
displayName: "Steckdose",
category: "single_phase",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
}));
assert.match(roomMarkup, /value="101"/);
assert.match(roomMarkup, /value="Büro"/);
assert.match(deviceMarkup, /value="socket"/);
assert.match(deviceMarkup, /value="Steckdose"/);
assert.match(deviceMarkup, /value="2"/);
});
it("converts mapped Revit watts into ProjectDevice kilowatts", () => {
const plan = {
familyGroups: [{
familyAndType: "Steckdose : Standard",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "230 V",
}],
objects: [{
sourceValues: { familyAndType: "Steckdose : Standard" },
suggestedPlanningValues: {
displayName: "Arbeitsplatzsteckdose",
effectiveQuantity: 4,
powerPerUnitW: 250,
simultaneityFactor: 0.8,
cosPhi: 0.95,
costGroup: "440",
remark: "Aus Revit",
},
}],
} as ExternalInitialImportPlanDto;
assert.deepEqual(
createRevitProjectDevicePrefill(plan, "Steckdose : Standard"),
{
name: "socket",
displayName: "Arbeitsplatzsteckdose",
connectionKind: "230 V",
category: "single_phase",
quantity: 4,
powerPerUnit: 0.25,
simultaneityFactor: 0.8,
cosPhi: 0.95,
costGroup: "440",
remark: "Aus Revit",
}
);
});
});