Add Revit catalog creation flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
</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>
|
||||
) : 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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +500,10 @@ export function RevitCsvImportPlan({
|
||||
<td>{sourceRoom.objectCount}</td>
|
||||
<td><RoomMatchBadge status={sourceRoom.matchStatus} /></td>
|
||||
<td>
|
||||
<div className="input-group input-group-sm">
|
||||
<select
|
||||
aria-label={`Interner Raum für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
|
||||
className="form-select form-select-sm"
|
||||
className="form-select"
|
||||
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
|
||||
...decision,
|
||||
roomId: event.target.value || null,
|
||||
@@ -404,6 +516,15 @@ export function RevitCsvImportPlan({
|
||||
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,9 +562,10 @@ export function RevitCsvImportPlan({
|
||||
: <span className="badge text-bg-danger">Regel fehlt</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="input-group input-group-sm">
|
||||
<select
|
||||
aria-label={`ProjectDevice für ${group.familyAndType}`}
|
||||
className="form-select form-select-sm"
|
||||
className="form-select"
|
||||
onChange={(event) => onFamilyLinkChange(group.familyAndType, event.target.value || null)}
|
||||
value={familyLinks[group.familyAndType] ?? ""}
|
||||
>
|
||||
@@ -452,6 +574,16 @@ export function RevitCsvImportPlan({
|
||||
<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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user