diff --git a/AGENTS.md b/AGENTS.md index 5f6473d..e15bf4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index a09cda3..cb46030 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -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 diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index ee2b2cc..f959dc4 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -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 diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index d5a6b87..c675340 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -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 = { name: "Technischer Name", @@ -101,9 +103,6 @@ export default function ProjectDetailPage() { const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [floorName, setFloorName] = useState(""); const [editingFloor, setEditingFloor] = useState(null); - const [roomNumber, setRoomNumber] = useState(""); - const [roomName, setRoomName] = useState(""); - const [roomFloorId, setRoomFloorId] = useState(""); const [editingRoom, setEditingRoom] = useState(null); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); @@ -285,24 +284,11 @@ export default function ProjectDetailPage() { } } - async function handleSaveRoom(event: FormEvent) { - 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" ? ( - { setStructureModal(null); setEditingRoom(null); }} - onSubmit={handleSaveRoom} - submitDisabled={!roomNumber.trim() || !roomName.trim()} - submitLabel={editingRoom ? "Änderungen speichern" : "Raum anlegen"} - title={editingRoom ? "Raum bearbeiten" : "Raum hinzufügen"} - > -
-
- - setRoomNumber(event.target.value)} - required - value={roomNumber} - /> -
-
- - setRoomName(event.target.value)} - required - value={roomName} - /> -
-
- - -
-
- {editingRoom ? ( -
-

- In Stromkreisen verwendete Räume können nicht gelöscht werden. -

- -
- ) : null} -
+ onDelete={editingRoom ? () => void handleDeleteRoom() : undefined} + onSave={handleSaveRoom} + /> ) : null} {isProjectDeviceModalOpen ? ( @@ -1692,6 +1626,8 @@ export default function ProjectDetailPage() { setIsRevitCsvOpen(false)} + onCreateProjectDevice={handleCreateRevitProjectDevice} + onCreateRoom={handleCreateRevitRoom} onRevisionChange={applyProjectRevision} projectId={projectId} /> diff --git a/src/frontend/components/project-device-modal.tsx b/src/frontend/components/project-device-modal.tsx index 55c9d25..1707da9 100644 --- a/src/frontend/components/project-device-modal.tsx +++ b/src/frontend/components/project-device-modal.tsx @@ -16,6 +16,7 @@ import { interface ProjectDeviceModalProps { globalDevices: GlobalDeviceDto[]; initialDevice?: ProjectDeviceDto; + initialValues?: Partial; isSaving: boolean; onClose: () => void; onImportGlobal: (globalDeviceId: string) => Promise; @@ -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 +) { 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 ?? "", }; } diff --git a/src/frontend/components/revit-csv-modal.tsx b/src/frontend/components/revit-csv-modal.tsx index f56f0f3..3602bd8 100644 --- a/src/frontend/components/revit-csv-modal.tsx +++ b/src/frontend/components/revit-csv-modal.tsx @@ -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; + onCreateRoom: (input: CreateRoomInput) => Promise; 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(null); + const [newProjectDeviceFamily, setNewProjectDeviceFamily] = useState(null); const [error, setError] = useState(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 ( + ({ ...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 ( + setNewProjectDeviceFamily(null)} + onImportGlobal={async () => undefined} + onSave={handleCreateProjectDevice} + /> + ); + } + } + return ( 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; 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({ {sourceRoom.objectCount} - +
+ + +
onFamilyLinkChange(group.familyAndType, event.target.value || null)} - value={familyLinks[group.familyAndType] ?? ""} - > - - {plan.existing.projectDevices.map((device) => ( - - ))} - +
+ + +
))} @@ -486,6 +618,37 @@ function categoryLabel(category: string | null) { return "Ohne Kategorie"; } +export function createRevitProjectDevicePrefill( + plan: ExternalInitialImportPlanDto, + familyAndType: string +): Partial { + 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
{label}
{value}
; } function fileToBase64(file: File) { diff --git a/src/frontend/components/room-modal.tsx b/src/frontend/components/room-modal.tsx new file mode 100644 index 0000000..e5648e0 --- /dev/null +++ b/src/frontend/components/room-modal.tsx @@ -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; + isSaving: boolean; + onClose: () => void; + onDelete?: () => void; + onSave: (input: CreateRoomInput) => Promise; +} + +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) { + event.preventDefault(); + if (!roomNumber.trim() || !roomName.trim()) return; + await onSave({ + floorId: floorId || undefined, + roomNumber: roomNumber.trim(), + roomName: roomName.trim(), + }); + } + + return ( + +
+
+ + setRoomNumber(event.target.value)} + required + value={roomNumber} + /> +
+
+ + setRoomName(event.target.value)} + required + value={roomName} + /> +
+
+ + +
+
+ {initialRoom && onDelete ? ( +
+

+ In Stromkreisen verwendete Räume können nicht gelöscht werden. +

+ +
+ ) : null} +
+ ); +} diff --git a/tests/revit-csv-frontend-api.test.ts b/tests/revit-csv-frontend-api.test.ts index 85846d7..d3695e5 100644 --- a/tests/revit-csv-frontend-api.test.ts +++ b/tests/revit-csv-frontend-api.test.ts @@ -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", + } + ); + }); });