From 18a4fdd8939f06acd099df463e14d7cde72ca47d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 1 May 2026 17:58:14 +0200 Subject: [PATCH] All first todos completed --- AGENTS.md | 21 +- package.json | 4 +- .../[projectId]/circuit-lists/page.tsx | 694 ++++++++++++++++-- src/app/projects/[projectId]/page.tsx | 136 +++- src/app/projects/page.tsx | 37 +- .../migrations/0006_device_display_name.sql | 7 + .../migrations/0007_consumer_device_link.sql | 3 + src/db/migrations/meta/_journal.json | 14 + src/db/repositories/consumer.repository.ts | 74 +- .../repositories/global-device.repository.ts | 2 + .../repositories/project-device.repository.ts | 2 + src/db/schema/consumers.ts | 5 + src/db/schema/global-devices.ts | 1 + src/db/schema/project-devices.ts | 1 + src/domain/models/consumer.model.ts | 2 + .../services/consumer-linking.service.ts | 36 + src/frontend/types.ts | 8 + src/frontend/utils/api.ts | 12 + src/server/controllers/consumer.controller.ts | 100 ++- .../controllers/global-device.controller.ts | 29 + .../controllers/project-device.controller.ts | 41 ++ src/server/routes/global-device.routes.ts | 2 + src/server/routes/project-device.routes.ts | 2 + src/shared/constants/consumer-option-lists.ts | 49 ++ src/shared/validation/consumer.schemas.ts | 36 +- .../validation/global-device.schemas.ts | 1 + .../validation/project-device.schemas.ts | 1 + tests/consumer-linking.service.test.ts | 65 ++ tests/consumer-schema-options.test.ts | 38 + 29 files changed, 1263 insertions(+), 160 deletions(-) create mode 100644 src/db/migrations/0006_device_display_name.sql create mode 100644 src/db/migrations/0007_consumer_device_link.sql create mode 100644 src/domain/services/consumer-linking.service.ts create mode 100644 src/shared/constants/consumer-option-lists.ts create mode 100644 tests/consumer-linking.service.test.ts create mode 100644 tests/consumer-schema-options.test.ts diff --git a/AGENTS.md b/AGENTS.md index 16f7b05..6c6de85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,5 @@ # AGENTS.md - ## Project Context - This repository contains a web application for creating, editing, calculating, and documenting electrical power balances for building-services electrical planning. The application is intended for small internal use by approximately 2–3 concurrent users. It should support practical planning workflows, not an over-engineered enterprise architecture. @@ -447,19 +445,8 @@ Use proper German umlauts (�, �, �, �, �, �, �) in all new or chan - By default, the table should initially hide: power factor (cos phi), phase count, and current. - Users must be able to add any available attribute as a table column at any time, and must be able to reorder column positions. +## Encoding Rule -## Open TODOs from docs/electrical-load-balance-requirements-context-dump.md - -- [x] Extend `CircuitEntry` data model with missing fields from requirements, especially `circuitNumber`, `description`, `deviceType`, `phaseType`, `tradeOrCostGroup`, `group`, `protectionType`, `protectionRatedCurrent`, `protectionCharacteristic`, `cableType`, `cableCrossSection`, `cableLength` and `comment`. -- [x] Allow multiple entries with the same `circuitNumber` and make this visible/editable in the circuit-list table. -- [x] Implement project-specific device lists (`ProjectDeviceList`) in backend + UI. -- [ ] Implement copying devices both directions between global and project-specific device lists. -- [ ] Add separate device naming model with `Device.name` and `Device.displayName`. -- [ ] Add explicit entry description field (`CircuitEntry.description`) independent of linked device naming. -- [ ] Implement device-link lifecycle on entries: link, unlink/detach, and update propagation from device changes to linked entries. -- [ ] Add `addCount` when adding a device to a circuit list to create multiple entries in one action (`addCount != quantity`). -- [ ] Relax circuit-entry validation so incomplete entries are possible (currently several fields are required). -- [ ] Add duplicate-entry action within the same circuit list (separate from copy to another list). -- [ ] Add sorting/filtering/bulk-edit capabilities for circuit-list tables (beyond current copy-selection flow). -- [ ] Define and implement fixed selection lists for domain fields (`deviceType`, `phaseType`, `tradeOrCostGroup`, `group`, protection and cable fields). -- [ ] Extend tests beyond pure power formulas to cover new circuit-entry/device-link behaviors once implemented. +- All text files must be saved as UTF-8. +- German UI text must never contain mojibake artifacts (for example geöffnet, wählen, Übernehmen, ←, →). +- If such artifacts appear, they must be corrected immediately before merge or handoff. diff --git a/package.json b/package.json index bc7f184..b2cc779 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate" }, diff --git a/src/app/projects/[projectId]/circuit-lists/page.tsx b/src/app/projects/[projectId]/circuit-lists/page.tsx index b909e56..26786fc 100644 --- a/src/app/projects/[projectId]/circuit-lists/page.tsx +++ b/src/app/projects/[projectId]/circuit-lists/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; @@ -12,7 +12,6 @@ import { listProjectDevices, listRooms, updateConsumer, - updateProjectSettings, } from "../../../../frontend/utils/api"; import type { ConsumerWithCalculatedValues, @@ -22,6 +21,16 @@ import type { ProjectDto, RoomDto, } from "../../../../frontend/types"; +import { + cableCrossSectionOptions, + cableTypeOptions, + consumerGroupOptions, + deviceTypeOptions, + phaseTypeOptions, + protectionCharacteristicOptions, + protectionTypeOptions, + tradeOrCostGroupOptions, +} from "../../../../shared/constants/consumer-option-lists"; interface SlotState { boardId: string; @@ -35,6 +44,22 @@ interface QuickCreateFormState { installedPowerPerUnitKw: string; demandFactor: string; totalPowerKw: string; + addCount: string; +} + +type SortField = "name" | "circuitNumber" | "quantity" | "installedPowerPerUnitKw" | "demandFactor" | "demandPowerKw"; +type SortDirection = "asc" | "desc"; + +interface ListFilterState { + query: string; + sortField: SortField; + sortDirection: SortDirection; +} + +interface BulkEditState { + quantity: string; + installedPowerPerUnitKw: string; + demandFactor: string; } type ColumnKey = @@ -42,9 +67,21 @@ type ColumnKey = | "circuitNumber" | "description" | "name" + | "projectDevice" + | "deviceLink" | "room" | "floor" | "category" + | "deviceType" + | "phaseType" + | "tradeOrCostGroup" + | "group" + | "protectionType" + | "protectionRatedCurrent" + | "protectionCharacteristic" + | "cableType" + | "cableCrossSection" + | "comment" | "quantity" | "installedPowerPerUnitKw" | "installedPowerKw" @@ -70,6 +107,19 @@ const defaultCreateForm: QuickCreateFormState = { installedPowerPerUnitKw: "0.10", demandFactor: "1.00", totalPowerKw: "0.10", + addCount: "1", +}; + +const defaultFilterState: ListFilterState = { + query: "", + sortField: "name", + sortDirection: "asc", +}; + +const defaultBulkEditState: BulkEditState = { + quantity: "", + installedPowerPerUnitKw: "", + demandFactor: "", }; const allColumns: Array<{ key: ColumnKey; label: string }> = [ @@ -77,9 +127,21 @@ const allColumns: Array<{ key: ColumnKey; label: string }> = [ { key: "circuitNumber", label: "Stromkreis-Nr." }, { key: "description", label: "Beschreibung" }, { key: "name", label: "Verbraucher" }, + { key: "projectDevice", label: "Projektgerät" }, + { key: "deviceLink", label: "Link" }, { key: "room", label: "Raum" }, { key: "floor", label: "Etage" }, { key: "category", label: "Kategorie" }, + { key: "deviceType", label: "Geräteart" }, + { key: "phaseType", label: "Phasenart" }, + { key: "tradeOrCostGroup", label: "Kostengruppe" }, + { key: "group", label: "Gruppe" }, + { key: "protectionType", label: "Schutzart" }, + { key: "protectionRatedCurrent", label: "Schutznennstrom [A]" }, + { key: "protectionCharacteristic", label: "Schutzkennlinie" }, + { key: "cableType", label: "Kabeltyp" }, + { key: "cableCrossSection", label: "Querschnitt" }, + { key: "comment", label: "Kommentar" }, { key: "quantity", label: "Anzahl" }, { key: "installedPowerPerUnitKw", label: "Einzelleistung [kW]" }, { key: "installedPowerKw", label: "Installierte Leistung [kW]" }, @@ -87,7 +149,7 @@ const allColumns: Array<{ key: ColumnKey; label: string }> = [ { key: "demandPowerKw", label: "Gesamtleistung [kW]" }, { key: "voltageV", label: "Spannung [V]" }, { key: "phaseCount", label: "Phasen" }, - { key: "powerFactor", label: "cos φ" }, + { key: "powerFactor", label: "cos f" }, { key: "currentA", label: "Strom [A]" }, { key: "note", label: "Bemerkung" }, { key: "actions", label: "Aktionen" }, @@ -98,6 +160,8 @@ const defaultVisibleColumns: ColumnKey[] = [ "circuitNumber", "description", "name", + "projectDevice", + "deviceLink", "room", "quantity", "installedPowerPerUnitKw", @@ -139,8 +203,16 @@ export default function CircuitListsPage() { 2: { ...defaultCreateForm }, }); const [visibleColumns, setVisibleColumns] = useState(defaultVisibleColumns); - const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState("230"); - const [threePhaseVoltageV, setThreePhaseVoltageV] = useState("400"); + const [listFilters, setListFilters] = useState>({ + 0: { ...defaultFilterState }, + 1: { ...defaultFilterState }, + 2: { ...defaultFilterState }, + }); + const [bulkEditForms, setBulkEditForms] = useState>({ + 0: { ...defaultBulkEditState }, + 1: { ...defaultBulkEditState }, + 2: { ...defaultBulkEditState }, + }); const [showColumnManager, setShowColumnManager] = useState(false); const [error, setError] = useState(null); const [isSaving, setIsSaving] = useState(false); @@ -163,8 +235,6 @@ export default function CircuitListsPage() { .then(([loadedProject, distributionBoards, loadedRooms, loadedProjectDevices, loadedConsumers]) => { const initialBoardId = searchParams.get("boardId") ?? distributionBoards[0]?.id ?? ""; setProject(loadedProject); - setSinglePhaseVoltageV(String(loadedProject.singlePhaseVoltageV)); - setThreePhaseVoltageV(String(loadedProject.threePhaseVoltageV)); setBoards(distributionBoards); setRooms(loadedRooms); setProjectDevices(loadedProjectDevices); @@ -190,6 +260,39 @@ export default function CircuitListsPage() { return consumers.filter((item) => item.distributionBoardId === boardId); } + function updateListFilter(slotIndex: number, patch: Partial) { + setListFilters((current) => ({ + ...current, + [slotIndex]: { ...current[slotIndex], ...patch }, + })); + } + + function updateBulkEditForm(slotIndex: number, patch: Partial) { + setBulkEditForms((current) => ({ + ...current, + [slotIndex]: { ...current[slotIndex], ...patch }, + })); + } + + function getComparableSortValue(consumer: ConsumerWithCalculatedValues, sortField: SortField): number | string { + switch (sortField) { + case "name": + return (consumer.name ?? "").toLocaleLowerCase("de-DE"); + case "circuitNumber": + return (consumer.circuitNumber ?? "").toLocaleLowerCase("de-DE"); + case "quantity": + return consumer.quantity ?? 0; + case "installedPowerPerUnitKw": + return consumer.installedPowerPerUnitKw ?? 0; + case "demandFactor": + return consumer.demandFactor ?? 0; + case "demandPowerKw": + return consumer.demandPowerKw ?? 0; + default: + return ""; + } + } + function setSlotBoard(slotIndex: number, boardId: string) { setSlots((current) => current.map((slot, index) => (index === slotIndex ? { boardId, selectedConsumerIds: [] } : slot)) @@ -266,26 +369,6 @@ export default function CircuitListsPage() { setConsumers(await listConsumers(projectId)); } - async function handleSaveProjectSettings() { - if (!projectId) { - return; - } - setIsSaving(true); - setError(null); - try { - const updated = await updateProjectSettings(projectId, { - singlePhaseVoltageV: Number(singlePhaseVoltageV), - threePhaseVoltageV: Number(threePhaseVoltageV), - }); - setProject(updated); - await reloadConsumers(); - } catch (err) { - setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - async function handleCreateManualConsumer(slotIndex: number, event: FormEvent) { event.preventDefault(); const slot = slots[slotIndex]; @@ -298,6 +381,7 @@ export default function CircuitListsPage() { const payload: CreateConsumerInput = { projectId, distributionBoardId: slot.boardId, + isLinkedToDevice: false, roomId: form.roomId || undefined, description: name, name, @@ -332,13 +416,17 @@ export default function CircuitListsPage() { if (!projectId || !slot.boardId || !projectDevice) { return; } + const addCountRaw = Number(form.addCount); + const addCount = Number.isFinite(addCountRaw) ? Math.max(1, Math.floor(addCountRaw)) : 1; const payload: CreateConsumerInput = { projectId, distributionBoardId: slot.boardId, + projectDeviceId: projectDevice.id, + isLinkedToDevice: true, roomId: form.roomId || undefined, - description: projectDevice.name, - name: projectDevice.name, + description: projectDevice.displayName, + name: projectDevice.displayName, category: projectDevice.category ?? undefined, quantity: projectDevice.quantity, installedPowerPerUnitKw: projectDevice.installedPowerPerUnitKw, @@ -351,7 +439,7 @@ export default function CircuitListsPage() { setIsSaving(true); setError(null); try { - await createConsumer(payload); + await Promise.all(Array.from({ length: addCount }, () => createConsumer(payload))); await reloadConsumers(); } catch (err) { setError(err instanceof Error ? err.message : "Verbraucher konnte nicht aus Projektgerät erstellt werden."); @@ -369,6 +457,8 @@ export default function CircuitListsPage() { const payload: CreateConsumerInput = { projectId, distributionBoardId: targetBoardId, + projectDeviceId: sourceConsumer.projectDeviceId ?? undefined, + isLinkedToDevice: sourceConsumer.isLinkedToDevice, circuitNumber: sourceConsumer.circuitNumber, description: sourceConsumer.description, roomId: sourceConsumer.roomId ?? undefined, @@ -405,6 +495,52 @@ export default function CircuitListsPage() { } } + async function handleDuplicateInSameList(sourceConsumer: ConsumerWithCalculatedValues) { + if (!projectId || !sourceConsumer.distributionBoardId) { + return; + } + + const payload: CreateConsumerInput = { + projectId, + distributionBoardId: sourceConsumer.distributionBoardId, + projectDeviceId: sourceConsumer.projectDeviceId ?? undefined, + isLinkedToDevice: sourceConsumer.isLinkedToDevice, + circuitNumber: sourceConsumer.circuitNumber, + description: sourceConsumer.description, + roomId: sourceConsumer.roomId ?? undefined, + name: sourceConsumer.name, + category: sourceConsumer.category, + deviceType: sourceConsumer.deviceType, + phaseType: sourceConsumer.phaseType, + tradeOrCostGroup: sourceConsumer.tradeOrCostGroup, + group: sourceConsumer.group, + protectionType: sourceConsumer.protectionType, + protectionRatedCurrent: sourceConsumer.protectionRatedCurrent, + protectionCharacteristic: sourceConsumer.protectionCharacteristic, + cableType: sourceConsumer.cableType, + cableCrossSection: sourceConsumer.cableCrossSection, + comment: sourceConsumer.comment, + quantity: sourceConsumer.quantity, + installedPowerPerUnitKw: sourceConsumer.installedPowerPerUnitKw, + demandFactor: sourceConsumer.demandFactor, + voltageV: sourceConsumer.voltageV, + phaseCount: sourceConsumer.phaseCount, + powerFactor: sourceConsumer.powerFactor, + note: sourceConsumer.note, + }; + + setIsSaving(true); + setError(null); + try { + await createConsumer(payload); + await reloadConsumers(); + } catch (err) { + setError(err instanceof Error ? err.message : "Duplizieren in derselben Liste fehlgeschlagen."); + } finally { + setIsSaving(false); + } + } + async function handleCopySelectionToSlot(sourceSlotIndex: number, targetSlotIndex: number) { const sourceSlot = slots[sourceSlotIndex]; const targetBoardId = slots[targetSlotIndex].boardId; @@ -425,6 +561,8 @@ export default function CircuitListsPage() { createConsumer({ projectId, distributionBoardId: targetBoardId, + projectDeviceId: sourceConsumer.projectDeviceId ?? undefined, + isLinkedToDevice: sourceConsumer.isLinkedToDevice, circuitNumber: sourceConsumer.circuitNumber, description: sourceConsumer.description, roomId: sourceConsumer.roomId ?? undefined, @@ -461,6 +599,59 @@ export default function CircuitListsPage() { } } + async function handleBulkEditSelection(slotIndex: number) { + const slot = slots[slotIndex]; + if (!slot.selectedConsumerIds.length) { + return; + } + + const form = bulkEditForms[slotIndex]; + const patch: Partial = {}; + const quantity = form.quantity.trim(); + const installedPowerPerUnitKw = form.installedPowerPerUnitKw.trim(); + const demandFactor = form.demandFactor.trim(); + + if (quantity) { + const value = Number(quantity); + if (!Number.isNaN(value) && value >= 0) { + patch.quantity = value; + } + } + if (installedPowerPerUnitKw) { + const value = Number(installedPowerPerUnitKw); + if (!Number.isNaN(value) && value >= 0) { + patch.installedPowerPerUnitKw = value; + } + } + if (demandFactor) { + const value = Number(demandFactor); + if (!Number.isNaN(value) && value >= 0 && value <= 1) { + patch.demandFactor = value; + } + } + + if (!Object.keys(patch).length) { + return; + } + + const selected = consumers.filter((consumer) => slot.selectedConsumerIds.includes(consumer.id)); + if (!selected.length) { + return; + } + + setIsSaving(true); + setError(null); + try { + await Promise.all(selected.map((consumer) => handleInlineUpdateFields(consumer, patch))); + await reloadConsumers(); + setBulkEditForms((current) => ({ ...current, [slotIndex]: { ...defaultBulkEditState } })); + } catch (err) { + setError(err instanceof Error ? err.message : "Bulk-Änderung fehlgeschlagen."); + } finally { + setIsSaving(false); + } + } + async function handleInlineUpdateFields( consumer: ConsumerWithCalculatedValues, patch: Partial @@ -472,6 +663,10 @@ export default function CircuitListsPage() { ? patch.distributionBoardId : consumer.distributionBoardId ?? undefined, circuitListId: patch.circuitListId !== undefined ? patch.circuitListId : consumer.circuitListId ?? undefined, + projectDeviceId: + patch.projectDeviceId !== undefined ? patch.projectDeviceId : consumer.projectDeviceId ?? undefined, + isLinkedToDevice: + patch.isLinkedToDevice !== undefined ? patch.isLinkedToDevice : consumer.isLinkedToDevice ?? false, circuitNumber: patch.circuitNumber !== undefined ? patch.circuitNumber : consumer.circuitNumber, description: patch.description !== undefined ? patch.description : consumer.description, roomId: patch.roomId !== undefined ? patch.roomId : consumer.roomId ?? undefined, @@ -593,48 +788,6 @@ export default function CircuitListsPage() { {error ?
{error}
: null} -
-
Projekteigenschaften
-
-
-
- - setSinglePhaseVoltageV(event.target.value)} - /> -
-
- - setThreePhaseVoltageV(event.target.value)} - /> -
-
- -
-
- - Ohne explizite Verbraucherspannung gilt automatisch 1-phasig = {singlePhaseVoltageV || "230"} V und - 3-phasig = {threePhaseVoltageV || "400"} V. - -
-
- {showColumnManager ? (
Spaltenauswahl und Reihenfolge
@@ -677,7 +830,7 @@ export default function CircuitListsPage() { ))} - Standardmäßig sind cos φ, Phasen und Strom ausgeblendet. Du kannst sie hier jederzeit einblenden. + Standardmäßig sind cos f, Phasen und Strom ausgeblendet. Du kannst sie hier jederzeit einblenden.
@@ -685,7 +838,38 @@ export default function CircuitListsPage() {
{slots.slice(0, activeListCount).map((slot, slotIndex) => { - const listConsumersForSlot = slot.boardId ? consumersForBoard(slot.boardId) : []; + const currentFilter = listFilters[slotIndex]; + const bulkEditForm = bulkEditForms[slotIndex]; + const baseConsumersForSlot = slot.boardId ? consumersForBoard(slot.boardId) : []; + const query = currentFilter.query.trim().toLocaleLowerCase("de-DE"); + const filteredConsumersForSlot = query + ? baseConsumersForSlot.filter((consumer) => { + const searchable = [ + consumer.circuitNumber ?? "", + consumer.description ?? "", + consumer.name ?? "", + consumer.category ?? "", + consumer.note ?? "", + consumer.roomNumber ?? "", + consumer.roomName ?? "", + consumer.floorName ?? "", + ] + .join(" ") + .toLocaleLowerCase("de-DE"); + return searchable.includes(query); + }) + : baseConsumersForSlot; + const listConsumersForSlot = [...filteredConsumersForSlot].sort((a, b) => { + const aValue = getComparableSortValue(a, currentFilter.sortField); + const bValue = getComparableSortValue(b, currentFilter.sortField); + let result = 0; + if (typeof aValue === "number" && typeof bValue === "number") { + result = aValue - bValue; + } else { + result = String(aValue).localeCompare(String(bValue), "de-DE", { sensitivity: "base" }); + } + return currentFilter.sortDirection === "asc" ? result : result * -1; + }); const quickForm = quickCreateForms[slotIndex]; return ( @@ -797,10 +981,20 @@ export default function CircuitListsPage() { {projectDevices.map((item) => ( ))} + updateQuickCreateForm(slotIndex, { addCount: event.target.value })} + title="Anzahl Einträge" + style={{ maxWidth: "8rem" }} + /> +
+ @@ -882,6 +1165,45 @@ export default function CircuitListsPage() { /> ); + case "projectDevice": + return ( + + ); + case "deviceLink": + return ( + + ); case "room": return ( ); + case "deviceType": + return ( + + ); + case "phaseType": + return ( + + ); + case "tradeOrCostGroup": + return ( + + ); + case "group": + return ( + + ); + case "protectionType": + return ( + + ); + case "protectionRatedCurrent": + return ( + + ); + case "protectionCharacteristic": + return ( + + ); + case "cableType": + return ( + + ); + case "cableCrossSection": + return ( + + ); + case "comment": + return ( + + ); case "quantity": return (
+ + + + @@ -921,6 +1243,217 @@ export default function CircuitListsPage() { /> + + + + + + + + + + + { + const raw = event.target.value.trim(); + const value = raw ? Number(raw) : undefined; + if (raw === "" && consumer.protectionRatedCurrent !== undefined) { + void handleInlineUpdateFields(consumer, { + protectionRatedCurrent: undefined, + }); + } else if ( + value !== undefined && + !Number.isNaN(value) && + value !== consumer.protectionRatedCurrent + ) { + void handleInlineUpdateFields(consumer, { protectionRatedCurrent: value }); + } + }} + /> + + + + + + + + + event.target.value !== (consumer.comment ?? "") + ? handleInlineUpdateFields(consumer, { + comment: event.target.value || undefined, + }) + : undefined + } + /> + @@ -1060,6 +1593,15 @@ export default function CircuitListsPage() { L{targetSlotIndex + 1} ))} + +
- + + - + @@ -613,6 +693,17 @@ export default function ProjectDetailPage() { } /> + ))} {!projectDevices.length ? ( - @@ -658,3 +760,5 @@ export default function ProjectDetailPage() { ); } + + diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index e2a8575..7397850 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -18,6 +18,7 @@ import type { const emptyGlobalDevice: CreateGlobalDeviceInput = { name: "", + displayName: "", category: "", quantity: 1, installedPowerPerUnitKw: 0.1, @@ -42,6 +43,7 @@ export default function ProjectsPage() { const [projectName, setProjectName] = useState(""); const [globalDeviceForm, setGlobalDeviceForm] = useState>({ name: "", + displayName: "", category: "", quantity: "1", installedPowerPerUnitKw: "0.1", @@ -92,6 +94,7 @@ export default function ProjectsPage() { } const payload: CreateGlobalDeviceInput = { name: globalDeviceForm.name.trim(), + displayName: globalDeviceForm.displayName.trim() || globalDeviceForm.name.trim(), category: globalDeviceForm.category.trim() || undefined, quantity: Number(globalDeviceForm.quantity), installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw), @@ -108,6 +111,7 @@ export default function ProjectsPage() { setGlobalDevices((current) => [...current, created]); setGlobalDeviceForm({ name: emptyGlobalDevice.name, + displayName: emptyGlobalDevice.displayName, category: emptyGlobalDevice.category ?? "", quantity: String(emptyGlobalDevice.quantity), installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw), @@ -139,11 +143,12 @@ export default function ProjectsPage() { async function handleQuickUpdateGlobalDevice( device: GlobalDeviceDto, - key: "name" | "category", + key: "name" | "displayName" | "category", value: string ) { const payload: CreateGlobalDeviceInput = { name: key === "name" ? value : device.name, + displayName: key === "displayName" ? value : device.displayName, category: key === "category" ? value : device.category ?? undefined, quantity: device.quantity, installedPowerPerUnitKw: device.installedPowerPerUnitKw, @@ -238,12 +243,20 @@ export default function ProjectsPage() {
setGlobalDeviceForm((current) => ({ ...current, name: event.target.value }))} />
-
+
+ setGlobalDeviceForm((current) => ({ ...current, displayName: event.target.value }))} + /> +
+
-
+
- + + @@ -337,6 +351,17 @@ export default function ProjectsPage() { } /> + diff --git a/src/db/migrations/0006_device_display_name.sql b/src/db/migrations/0006_device_display_name.sql new file mode 100644 index 0000000..ce753a0 --- /dev/null +++ b/src/db/migrations/0006_device_display_name.sql @@ -0,0 +1,7 @@ +ALTER TABLE `global_devices` ADD COLUMN `display_name` text; +--> statement-breakpoint +UPDATE `global_devices` SET `display_name` = `name` WHERE `display_name` IS NULL; +--> statement-breakpoint +ALTER TABLE `project_devices` ADD COLUMN `display_name` text; +--> statement-breakpoint +UPDATE `project_devices` SET `display_name` = `name` WHERE `display_name` IS NULL; diff --git a/src/db/migrations/0007_consumer_device_link.sql b/src/db/migrations/0007_consumer_device_link.sql new file mode 100644 index 0000000..d5d667d --- /dev/null +++ b/src/db/migrations/0007_consumer_device_link.sql @@ -0,0 +1,3 @@ +ALTER TABLE `consumers` ADD COLUMN `project_device_id` text REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `is_linked_to_device` integer NOT NULL DEFAULT 0; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 2193b14..34ed598 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1777597000000, "tag": "0005_project_devices", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1777652000000, + "tag": "0006_device_display_name", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1777680000000, + "tag": "0007_consumer_device_link", + "breakpoints": true } ] } diff --git a/src/db/repositories/consumer.repository.ts b/src/db/repositories/consumer.repository.ts index d8dc744..7973ae0 100644 --- a/src/db/repositories/consumer.repository.ts +++ b/src/db/repositories/consumer.repository.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "../client.js"; import { consumers } from "../schema/consumers.js"; import type { @@ -14,15 +14,21 @@ export class ConsumerRepository { async create(input: CreateConsumerInput) { const id = crypto.randomUUID(); + const normalizedName = input.name?.trim() || "Unbenannter Eintrag"; + const normalizedQuantity = input.quantity ?? 0; + const normalizedInstalledPowerPerUnitKw = input.installedPowerPerUnitKw ?? 0; + const normalizedDemandFactor = input.demandFactor ?? 1; await db.insert(consumers).values({ id, projectId: input.projectId, distributionBoardId: input.distributionBoardId ?? null, circuitListId: input.circuitListId ?? null, + projectDeviceId: input.projectDeviceId ?? null, + isLinkedToDevice: input.isLinkedToDevice ? 1 : 0, roomId: input.roomId ?? null, circuitNumber: input.circuitNumber ?? null, description: input.description ?? null, - name: input.name, + name: normalizedName, category: input.category ?? null, deviceType: input.deviceType ?? null, phaseType: input.phaseType ?? null, @@ -34,28 +40,41 @@ export class ConsumerRepository { cableType: input.cableType ?? null, cableCrossSection: input.cableCrossSection ?? null, comment: input.comment ?? null, - quantity: input.quantity, - installedPowerPerUnitKw: input.installedPowerPerUnitKw, - demandFactor: input.demandFactor, + quantity: normalizedQuantity, + installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw, + demandFactor: normalizedDemandFactor, voltageV: input.voltageV ?? null, phaseCount: input.phaseCount ?? null, powerFactor: input.powerFactor ?? null, note: input.note ?? null, }); - return { id, ...input }; + return { + id, + ...input, + name: normalizedName, + quantity: normalizedQuantity, + installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw, + demandFactor: normalizedDemandFactor, + }; } async update(consumerId: string, input: UpdateConsumerInput) { + const normalizedName = input.name?.trim() || "Unbenannter Eintrag"; + const normalizedQuantity = input.quantity ?? 0; + const normalizedInstalledPowerPerUnitKw = input.installedPowerPerUnitKw ?? 0; + const normalizedDemandFactor = input.demandFactor ?? 1; await db .update(consumers) .set({ projectId: input.projectId, distributionBoardId: input.distributionBoardId ?? null, circuitListId: input.circuitListId ?? null, + projectDeviceId: input.projectDeviceId ?? null, + isLinkedToDevice: input.isLinkedToDevice ? 1 : 0, roomId: input.roomId ?? null, circuitNumber: input.circuitNumber ?? null, description: input.description ?? null, - name: input.name, + name: normalizedName, category: input.category ?? null, deviceType: input.deviceType ?? null, phaseType: input.phaseType ?? null, @@ -67,9 +86,9 @@ export class ConsumerRepository { cableType: input.cableType ?? null, cableCrossSection: input.cableCrossSection ?? null, comment: input.comment ?? null, - quantity: input.quantity, - installedPowerPerUnitKw: input.installedPowerPerUnitKw, - demandFactor: input.demandFactor, + quantity: normalizedQuantity, + installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw, + demandFactor: normalizedDemandFactor, voltageV: input.voltageV ?? null, phaseCount: input.phaseCount ?? null, powerFactor: input.powerFactor ?? null, @@ -86,4 +105,39 @@ export class ConsumerRepository { async delete(consumerId: string) { await db.delete(consumers).where(eq(consumers.id, consumerId)); } + + async syncLinkedConsumersFromProjectDevice( + projectId: string, + projectDeviceId: string, + data: { + displayName: string; + category?: string; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + phaseCount?: 1 | 3; + powerFactor?: number; + note?: string; + } + ) { + await db + .update(consumers) + .set({ + name: data.displayName, + category: data.category ?? null, + quantity: data.quantity, + installedPowerPerUnitKw: data.installedPowerPerUnitKw, + demandFactor: data.demandFactor, + phaseCount: data.phaseCount ?? null, + powerFactor: data.powerFactor ?? null, + note: data.note ?? null, + }) + .where( + and( + eq(consumers.projectId, projectId), + eq(consumers.projectDeviceId, projectDeviceId), + eq(consumers.isLinkedToDevice, 1) + ) + ); + } } diff --git a/src/db/repositories/global-device.repository.ts b/src/db/repositories/global-device.repository.ts index 087bfcf..0a8b618 100644 --- a/src/db/repositories/global-device.repository.ts +++ b/src/db/repositories/global-device.repository.ts @@ -17,6 +17,7 @@ export class GlobalDeviceRepository { await db.insert(globalDevices).values({ id, name: input.name, + displayName: input.displayName, category: input.category ?? null, quantity: input.quantity, installedPowerPerUnitKw: input.installedPowerPerUnitKw, @@ -34,6 +35,7 @@ export class GlobalDeviceRepository { .update(globalDevices) .set({ name: input.name, + displayName: input.displayName, category: input.category ?? null, quantity: input.quantity, installedPowerPerUnitKw: input.installedPowerPerUnitKw, diff --git a/src/db/repositories/project-device.repository.ts b/src/db/repositories/project-device.repository.ts index 009f01a..6c1e1f7 100644 --- a/src/db/repositories/project-device.repository.ts +++ b/src/db/repositories/project-device.repository.ts @@ -18,6 +18,7 @@ export class ProjectDeviceRepository { id, projectId, name: input.name, + displayName: input.displayName, category: input.category ?? null, quantity: input.quantity, installedPowerPerUnitKw: input.installedPowerPerUnitKw, @@ -46,6 +47,7 @@ export class ProjectDeviceRepository { .update(projectDevices) .set({ name: input.name, + displayName: input.displayName, category: input.category ?? null, quantity: input.quantity, installedPowerPerUnitKw: input.installedPowerPerUnitKw, diff --git a/src/db/schema/consumers.ts b/src/db/schema/consumers.ts index 8443fc3..a7ee83d 100644 --- a/src/db/schema/consumers.ts +++ b/src/db/schema/consumers.ts @@ -1,6 +1,7 @@ import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { circuitLists } from "./circuit-lists.js"; import { distributionBoards } from "./distribution-boards.js"; +import { projectDevices } from "./project-devices.js"; import { projects } from "./projects.js"; import { rooms } from "./rooms.js"; @@ -15,6 +16,10 @@ export const consumers = sqliteTable("consumers", { circuitListId: text("circuit_list_id").references(() => circuitLists.id, { onDelete: "set null", }), + projectDeviceId: text("project_device_id").references(() => projectDevices.id, { + onDelete: "set null", + }), + isLinkedToDevice: integer("is_linked_to_device").notNull().default(0), roomId: text("room_id").references(() => rooms.id, { onDelete: "set null", }), diff --git a/src/db/schema/global-devices.ts b/src/db/schema/global-devices.ts index 2b47c91..f47900c 100644 --- a/src/db/schema/global-devices.ts +++ b/src/db/schema/global-devices.ts @@ -3,6 +3,7 @@ import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const globalDevices = sqliteTable("global_devices", { id: text("id").primaryKey(), name: text("name").notNull(), + displayName: text("display_name").notNull(), category: text("category"), quantity: integer("quantity").notNull(), installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(), diff --git a/src/db/schema/project-devices.ts b/src/db/schema/project-devices.ts index c6b5ca3..667d6ae 100644 --- a/src/db/schema/project-devices.ts +++ b/src/db/schema/project-devices.ts @@ -7,6 +7,7 @@ export const projectDevices = sqliteTable("project_devices", { .notNull() .references(() => projects.id, { onDelete: "cascade" }), name: text("name").notNull(), + displayName: text("display_name").notNull(), category: text("category"), quantity: integer("quantity").notNull(), installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(), diff --git a/src/domain/models/consumer.model.ts b/src/domain/models/consumer.model.ts index 8cf2cc7..ee5b11a 100644 --- a/src/domain/models/consumer.model.ts +++ b/src/domain/models/consumer.model.ts @@ -3,6 +3,8 @@ export interface Consumer { projectId: string; distributionBoardId?: string; circuitListId?: string; + projectDeviceId?: string; + isLinkedToDevice?: boolean; roomId?: string; roomNumber?: string; roomName?: string; diff --git a/src/domain/services/consumer-linking.service.ts b/src/domain/services/consumer-linking.service.ts new file mode 100644 index 0000000..7c93c8f --- /dev/null +++ b/src/domain/services/consumer-linking.service.ts @@ -0,0 +1,36 @@ +import type { CreateConsumerInput } from "../../shared/validation/consumer.schemas.js"; + +type ProjectDeviceForLinking = { + displayName: string; + category: string | null; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + phaseCount: number | null; + powerFactor: number | null; + note: string | null; +}; + +export function applyLinkedProjectDeviceValues( + input: CreateConsumerInput, + projectDevice: ProjectDeviceForLinking | null +): CreateConsumerInput { + if (!input.projectDeviceId || !input.isLinkedToDevice || !projectDevice) { + return input; + } + + return { + ...input, + name: projectDevice.displayName, + category: projectDevice.category ?? undefined, + quantity: projectDevice.quantity, + installedPowerPerUnitKw: projectDevice.installedPowerPerUnitKw, + demandFactor: projectDevice.demandFactor, + phaseCount: + projectDevice.phaseCount === 1 || projectDevice.phaseCount === 3 + ? (projectDevice.phaseCount as 1 | 3) + : undefined, + powerFactor: projectDevice.powerFactor ?? undefined, + note: projectDevice.note ?? undefined, + }; +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 7025de6..e6033d2 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -10,6 +10,8 @@ export interface ConsumerWithCalculatedValues { projectId: string; distributionBoardId?: string | null; circuitListId?: string | null; + projectDeviceId?: string | null; + isLinkedToDevice?: boolean; roomId?: string | null; roomNumber?: string; roomName?: string; @@ -73,6 +75,7 @@ export interface RoomDto { export interface GlobalDeviceDto { id: string; name: string; + displayName: string; category: string | null; quantity: number; installedPowerPerUnitKw: number; @@ -87,6 +90,7 @@ export interface ProjectDeviceDto { id: string; projectId: string; name: string; + displayName: string; category: string | null; quantity: number; installedPowerPerUnitKw: number; @@ -101,6 +105,8 @@ export interface CreateConsumerInput { projectId: string; distributionBoardId?: string; circuitListId?: string; + projectDeviceId?: string; + isLinkedToDevice?: boolean; roomId?: string; circuitNumber?: string; description?: string; @@ -139,6 +145,7 @@ export interface CreateRoomInput { export interface CreateGlobalDeviceInput { name: string; + displayName: string; category?: string; quantity: number; installedPowerPerUnitKw: number; @@ -151,6 +158,7 @@ export interface CreateGlobalDeviceInput { export interface CreateProjectDeviceInput { name: string; + displayName: string; category?: string; quantity: number; installedPowerPerUnitKw: number; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 1e2bc06..92b3729 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -143,6 +143,12 @@ export function deleteGlobalDevice(globalDeviceId: string) { return request(`/api/global-devices/${globalDeviceId}`, { method: "DELETE" }); } +export function copyProjectDeviceToGlobal(projectId: string, projectDeviceId: string) { + return request(`/api/global-devices/import-project/${projectId}/${projectDeviceId}`, { + method: "POST", + }); +} + export function listProjectDevices(projectId: string) { return request(`/api/project-devices/projects/${projectId}`); } @@ -170,3 +176,9 @@ export function deleteProjectDevice(projectId: string, projectDeviceId: string) method: "DELETE", }); } + +export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) { + return request(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, { + method: "POST", + }); +} diff --git a/src/server/controllers/consumer.controller.ts b/src/server/controllers/consumer.controller.ts index 7cf78fd..cd92262 100644 --- a/src/server/controllers/consumer.controller.ts +++ b/src/server/controllers/consumer.controller.ts @@ -3,11 +3,14 @@ import { CircuitListRepository } from "../../db/repositories/circuit-list.reposi import { ConsumerRepository } from "../../db/repositories/consumer.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { FloorRepository } from "../../db/repositories/floor.repository.js"; +import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js"; import type { Consumer } from "../../domain/models/consumer.model.js"; +import { applyLinkedProjectDeviceValues } from "../../domain/services/consumer-linking.service.js"; import { PowerBalanceService } from "../../domain/services/power-balance.service.js"; import { + type CreateConsumerInput, createConsumerSchema, updateConsumerSchema, } from "../../shared/validation/consumer.schemas.js"; @@ -16,6 +19,7 @@ const circuitListRepository = new CircuitListRepository(); const consumerRepository = new ConsumerRepository(); const distributionBoardRepository = new DistributionBoardRepository(); const floorRepository = new FloorRepository(); +const projectDeviceRepository = new ProjectDeviceRepository(); const projectRepository = new ProjectRepository(); const roomRepository = new RoomRepository(); const powerBalanceService = new PowerBalanceService(); @@ -25,6 +29,8 @@ type ConsumerRow = { projectId: string; distributionBoardId: string | null; circuitListId: string | null; + projectDeviceId: string | null; + isLinkedToDevice: number; roomId: string | null; circuitNumber: string | null; description: string | null; @@ -66,6 +72,22 @@ async function validateRoomOwnership(projectId: string, roomId: string | undefin return roomRepository.existsInProject(projectId, roomId); } +async function validateProjectDeviceOwnership(projectId: string, projectDeviceId: string | undefined) { + if (!projectDeviceId) { + return true; + } + const row = await projectDeviceRepository.findById(projectId, projectDeviceId); + return Boolean(row); +} + +async function applyDeviceLinkIfNeeded(input: CreateConsumerInput): Promise { + if (!input.projectDeviceId || !input.isLinkedToDevice) { + return input; + } + const device = await projectDeviceRepository.findById(input.projectId, input.projectDeviceId); + return applyLinkedProjectDeviceValues(input, device); +} + async function resolveCircuitScope(input: { projectId: string; distributionBoardId?: string; @@ -125,6 +147,8 @@ function buildConsumerFromRow( projectId: row.projectId, distributionBoardId: row.distributionBoardId ?? undefined, circuitListId: row.circuitListId ?? undefined, + projectDeviceId: row.projectDeviceId ?? undefined, + isLinkedToDevice: Boolean(row.isLinkedToDevice), roomId: row.roomId ?? undefined, roomNumber: room?.roomNumber, roomName: room?.roomName, @@ -195,9 +219,18 @@ export async function createConsumer(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - const [hasValidDistributionBoard, hasValidRoom] = await Promise.all([ - validateDistributionBoardOwnership(parsed.data.projectId, parsed.data.distributionBoardId), - validateRoomOwnership(parsed.data.projectId, parsed.data.roomId), + const normalizedData: CreateConsumerInput = { + ...parsed.data, + name: parsed.data.name?.trim() || "Unbenannter Eintrag", + quantity: parsed.data.quantity ?? 0, + installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw ?? 0, + demandFactor: parsed.data.demandFactor ?? 1, + }; + + const [hasValidDistributionBoard, hasValidRoom, hasValidProjectDevice] = await Promise.all([ + validateDistributionBoardOwnership(normalizedData.projectId, normalizedData.distributionBoardId), + validateRoomOwnership(normalizedData.projectId, normalizedData.roomId), + validateProjectDeviceOwnership(normalizedData.projectId, normalizedData.projectDeviceId), ]); if (!hasValidDistributionBoard) { return res @@ -207,28 +240,34 @@ export async function createConsumer(req: Request, res: Response) { if (!hasValidRoom) { return res.status(400).json({ error: "Room does not belong to the provided project." }); } + if (!hasValidProjectDevice) { + return res.status(400).json({ error: "Project device does not belong to the provided project." }); + } + if (normalizedData.isLinkedToDevice && !normalizedData.projectDeviceId) { + return res.status(400).json({ error: "Linked entries require a projectDeviceId." }); + } const resolvedScope = await resolveCircuitScope({ - projectId: parsed.data.projectId, - distributionBoardId: parsed.data.distributionBoardId, - circuitListId: parsed.data.circuitListId, + projectId: normalizedData.projectId, + distributionBoardId: normalizedData.distributionBoardId, + circuitListId: normalizedData.circuitListId, }); if (!resolvedScope.ok) { return res.status(400).json({ error: resolvedScope.error }); } - const payload = { - ...parsed.data, + const payload = await applyDeviceLinkIfNeeded({ + ...normalizedData, distributionBoardId: resolvedScope.distributionBoardId, circuitListId: resolvedScope.circuitListId, - description: parsed.data.description ?? parsed.data.name, - }; + description: normalizedData.description ?? normalizedData.name, + }); const created = await consumerRepository.create(payload); const [project, floors, rooms] = await Promise.all([ - projectRepository.findById(parsed.data.projectId), - floorRepository.listByProject(parsed.data.projectId), - roomRepository.listByProject(parsed.data.projectId), + projectRepository.findById(normalizedData.projectId), + floorRepository.listByProject(normalizedData.projectId), + roomRepository.listByProject(normalizedData.projectId), ]); const roomById = new Map( rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }]) @@ -269,9 +308,18 @@ export async function updateConsumer(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - const [hasValidDistributionBoard, hasValidRoom] = await Promise.all([ - validateDistributionBoardOwnership(parsed.data.projectId, parsed.data.distributionBoardId), - validateRoomOwnership(parsed.data.projectId, parsed.data.roomId), + const normalizedData: CreateConsumerInput = { + ...parsed.data, + name: parsed.data.name?.trim() || "Unbenannter Eintrag", + quantity: parsed.data.quantity ?? 0, + installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw ?? 0, + demandFactor: parsed.data.demandFactor ?? 1, + }; + + const [hasValidDistributionBoard, hasValidRoom, hasValidProjectDevice] = await Promise.all([ + validateDistributionBoardOwnership(normalizedData.projectId, normalizedData.distributionBoardId), + validateRoomOwnership(normalizedData.projectId, normalizedData.roomId), + validateProjectDeviceOwnership(normalizedData.projectId, normalizedData.projectDeviceId), ]); if (!hasValidDistributionBoard) { return res @@ -281,23 +329,31 @@ export async function updateConsumer(req: Request, res: Response) { if (!hasValidRoom) { return res.status(400).json({ error: "Room does not belong to the provided project." }); } + if (!hasValidProjectDevice) { + return res.status(400).json({ error: "Project device does not belong to the provided project." }); + } + if (normalizedData.isLinkedToDevice && !normalizedData.projectDeviceId) { + return res.status(400).json({ error: "Linked entries require a projectDeviceId." }); + } const resolvedScope = await resolveCircuitScope({ - projectId: parsed.data.projectId, - distributionBoardId: parsed.data.distributionBoardId, - circuitListId: parsed.data.circuitListId, + projectId: normalizedData.projectId, + distributionBoardId: normalizedData.distributionBoardId, + circuitListId: normalizedData.circuitListId, }); if (!resolvedScope.ok) { return res.status(400).json({ error: resolvedScope.error }); } - await consumerRepository.update(consumerId, { - ...parsed.data, + const payload = await applyDeviceLinkIfNeeded({ + ...normalizedData, distributionBoardId: resolvedScope.distributionBoardId, circuitListId: resolvedScope.circuitListId, - description: parsed.data.description ?? parsed.data.name, + description: normalizedData.description ?? normalizedData.name, }); + await consumerRepository.update(consumerId, payload); + const row = await consumerRepository.findById(consumerId); if (!row) { return res.status(404).json({ error: "Consumer not found" }); diff --git a/src/server/controllers/global-device.controller.ts b/src/server/controllers/global-device.controller.ts index bc065db..fb00eba 100644 --- a/src/server/controllers/global-device.controller.ts +++ b/src/server/controllers/global-device.controller.ts @@ -1,11 +1,13 @@ import type { Request, Response } from "express"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; +import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { createGlobalDeviceSchema, updateGlobalDeviceSchema, } from "../../shared/validation/global-device.schemas.js"; const globalDeviceRepository = new GlobalDeviceRepository(); +const projectDeviceRepository = new ProjectDeviceRepository(); export async function listGlobalDevices(_req: Request, res: Response) { const rows = await globalDeviceRepository.list(); @@ -50,3 +52,30 @@ export async function deleteGlobalDevice(req: Request, res: Response) { await globalDeviceRepository.delete(globalDeviceId); return res.status(204).send(); } + +export async function copyProjectDeviceToGlobal(req: Request, res: Response) { + const { projectId, projectDeviceId } = req.params; + if (typeof projectId !== "string" || typeof projectDeviceId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + + const source = await projectDeviceRepository.findById(projectId, projectDeviceId); + if (!source) { + return res.status(404).json({ error: "Project device not found" }); + } + + const created = await globalDeviceRepository.create({ + name: source.name, + displayName: source.displayName, + category: source.category ?? undefined, + quantity: source.quantity, + installedPowerPerUnitKw: source.installedPowerPerUnitKw, + demandFactor: source.demandFactor, + voltageV: source.voltageV ?? undefined, + phaseCount: source.phaseCount === 1 || source.phaseCount === 3 ? source.phaseCount : undefined, + powerFactor: source.powerFactor ?? undefined, + note: source.note ?? undefined, + }); + + return res.status(201).json(created); +} diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index b76baa6..b1ce30f 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -1,10 +1,14 @@ import type { Request, Response } from "express"; +import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; +import { ConsumerRepository } from "../../db/repositories/consumer.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { createProjectDeviceSchema, updateProjectDeviceSchema, } from "../../shared/validation/project-device.schemas.js"; +const globalDeviceRepository = new GlobalDeviceRepository(); +const consumerRepository = new ConsumerRepository(); const projectDeviceRepository = new ProjectDeviceRepository(); export async function listProjectDevicesByProject(req: Request, res: Response) { @@ -41,6 +45,16 @@ export async function updateProjectDevice(req: Request, res: Response) { } await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data); + await consumerRepository.syncLinkedConsumersFromProjectDevice(projectId, projectDeviceId, { + displayName: parsed.data.displayName, + category: parsed.data.category, + quantity: parsed.data.quantity, + installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw, + demandFactor: parsed.data.demandFactor, + phaseCount: parsed.data.phaseCount, + powerFactor: parsed.data.powerFactor, + note: parsed.data.note, + }); const row = await projectDeviceRepository.findById(projectId, projectDeviceId); if (!row) { return res.status(404).json({ error: "Project device not found" }); @@ -57,3 +71,30 @@ export async function deleteProjectDevice(req: Request, res: Response) { await projectDeviceRepository.delete(projectId, projectDeviceId); return res.status(204).send(); } + +export async function copyGlobalDeviceToProject(req: Request, res: Response) { + const { projectId, globalDeviceId } = req.params; + if (typeof projectId !== "string" || typeof globalDeviceId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + + const source = await globalDeviceRepository.findById(globalDeviceId); + if (!source) { + return res.status(404).json({ error: "Global device not found" }); + } + + const created = await projectDeviceRepository.create(projectId, { + name: source.name, + displayName: source.displayName, + category: source.category ?? undefined, + quantity: source.quantity, + installedPowerPerUnitKw: source.installedPowerPerUnitKw, + demandFactor: source.demandFactor, + voltageV: source.voltageV ?? undefined, + phaseCount: source.phaseCount === 1 || source.phaseCount === 3 ? source.phaseCount : undefined, + powerFactor: source.powerFactor ?? undefined, + note: source.note ?? undefined, + }); + + return res.status(201).json(created); +} diff --git a/src/server/routes/global-device.routes.ts b/src/server/routes/global-device.routes.ts index 775a979..3edd0dd 100644 --- a/src/server/routes/global-device.routes.ts +++ b/src/server/routes/global-device.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { + copyProjectDeviceToGlobal, createGlobalDevice, deleteGlobalDevice, listGlobalDevices, @@ -9,6 +10,7 @@ import { export const globalDeviceRouter = Router(); globalDeviceRouter.get("/", listGlobalDevices); +globalDeviceRouter.post("/import-project/:projectId/:projectDeviceId", copyProjectDeviceToGlobal); globalDeviceRouter.post("/", createGlobalDevice); globalDeviceRouter.put("/:globalDeviceId", updateGlobalDevice); globalDeviceRouter.delete("/:globalDeviceId", deleteGlobalDevice); diff --git a/src/server/routes/project-device.routes.ts b/src/server/routes/project-device.routes.ts index 17441fe..a108bb2 100644 --- a/src/server/routes/project-device.routes.ts +++ b/src/server/routes/project-device.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { + copyGlobalDeviceToProject, createProjectDevice, deleteProjectDevice, listProjectDevicesByProject, @@ -10,5 +11,6 @@ export const projectDeviceRouter = Router(); projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject); projectDeviceRouter.post("/projects/:projectId", createProjectDevice); +projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject); projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice); projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice); diff --git a/src/shared/constants/consumer-option-lists.ts b/src/shared/constants/consumer-option-lists.ts new file mode 100644 index 0000000..6762209 --- /dev/null +++ b/src/shared/constants/consumer-option-lists.ts @@ -0,0 +1,49 @@ +export const deviceTypeOptions = [ + "Beleuchtung", + "Steckdose", + "Heizung", + "Kühlung", + "Lüftung", + "Antrieb", + "Sicherheit", + "IT", + "Sonstiges", +] as const; + +export const phaseTypeOptions = ["1-phasig", "3-phasig"] as const; + +export const tradeOrCostGroupOptions = [ + "KG 440 Starkstromanlagen", + "KG 450 Fernmelde- und informationstechnische Anlagen", + "KG 460 Förderanlagen", + "KG 470 Nutzungsspezifische Anlagen", + "KG 480 Gebäude- und Anlagenautomation", + "Sonstiges", +] as const; + +export const consumerGroupOptions = [ + "Allgemein", + "Notstrom", + "Sicherheitsstrom", + "USV", + "Technik", + "Reserve", +] as const; + +export const protectionTypeOptions = ["LS", "Schmelzsicherung", "Leistungsschalter", "FI/LS"] as const; + +export const protectionCharacteristicOptions = ["B", "C", "D", "K", "Z"] as const; + +export const cableTypeOptions = ["NYM-J", "NYY-J", "H07RN-F", "NHXMH-J", "Sonstiges"] as const; + +export const cableCrossSectionOptions = [ + "1,5 mm²", + "2,5 mm²", + "4 mm²", + "6 mm²", + "10 mm²", + "16 mm²", + "25 mm²", + "35 mm²", + "50 mm²", +] as const; diff --git a/src/shared/validation/consumer.schemas.ts b/src/shared/validation/consumer.schemas.ts index 4868a92..ab8b0d9 100644 --- a/src/shared/validation/consumer.schemas.ts +++ b/src/shared/validation/consumer.schemas.ts @@ -1,27 +1,39 @@ import { z } from "zod"; +import { + cableCrossSectionOptions, + cableTypeOptions, + consumerGroupOptions, + deviceTypeOptions, + phaseTypeOptions, + protectionCharacteristicOptions, + protectionTypeOptions, + tradeOrCostGroupOptions, +} from "../constants/consumer-option-lists.js"; export const createConsumerSchema = z.object({ projectId: z.string().min(1), distributionBoardId: z.string().min(1).optional(), circuitListId: z.string().min(1).optional(), + projectDeviceId: z.string().min(1).optional(), + isLinkedToDevice: z.boolean().optional(), roomId: z.string().min(1).optional(), circuitNumber: z.string().optional(), description: z.string().optional(), - name: z.string().min(1), + name: z.string().optional(), category: z.string().optional(), - deviceType: z.string().optional(), - phaseType: z.string().optional(), - tradeOrCostGroup: z.string().optional(), - group: z.string().optional(), - protectionType: z.string().optional(), + deviceType: z.enum(deviceTypeOptions).optional(), + phaseType: z.enum(phaseTypeOptions).optional(), + tradeOrCostGroup: z.enum(tradeOrCostGroupOptions).optional(), + group: z.enum(consumerGroupOptions).optional(), + protectionType: z.enum(protectionTypeOptions).optional(), protectionRatedCurrent: z.number().min(0).optional(), - protectionCharacteristic: z.string().optional(), - cableType: z.string().optional(), - cableCrossSection: z.string().optional(), + protectionCharacteristic: z.enum(protectionCharacteristicOptions).optional(), + cableType: z.enum(cableTypeOptions).optional(), + cableCrossSection: z.enum(cableCrossSectionOptions).optional(), comment: z.string().optional(), - quantity: z.number().min(0), - installedPowerPerUnitKw: z.number().min(0), - demandFactor: z.number().min(0).max(1), + quantity: z.number().min(0).optional(), + installedPowerPerUnitKw: z.number().min(0).optional(), + demandFactor: z.number().min(0).max(1).optional(), voltageV: z.number().positive().optional(), phaseCount: z.union([z.literal(1), z.literal(3)]).optional(), powerFactor: z.number().min(0).max(1).optional(), diff --git a/src/shared/validation/global-device.schemas.ts b/src/shared/validation/global-device.schemas.ts index ade12a4..8d55783 100644 --- a/src/shared/validation/global-device.schemas.ts +++ b/src/shared/validation/global-device.schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const createGlobalDeviceSchema = z.object({ name: z.string().min(1), + displayName: z.string().min(1), category: z.string().optional(), quantity: z.number().min(0), installedPowerPerUnitKw: z.number().min(0), diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts index ad56a71..2f85470 100644 --- a/src/shared/validation/project-device.schemas.ts +++ b/src/shared/validation/project-device.schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const createProjectDeviceSchema = z.object({ name: z.string().min(1), + displayName: z.string().min(1), category: z.string().optional(), quantity: z.number().min(0), installedPowerPerUnitKw: z.number().min(0), diff --git a/tests/consumer-linking.service.test.ts b/tests/consumer-linking.service.test.ts new file mode 100644 index 0000000..37f8009 --- /dev/null +++ b/tests/consumer-linking.service.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { applyLinkedProjectDeviceValues } from "../src/domain/services/consumer-linking.service.js"; + +describe("consumer linking service", () => { + it("applies project-device values to linked consumers", () => { + const input = { + projectId: "p1", + projectDeviceId: "d1", + isLinkedToDevice: true, + name: "Alt", + quantity: 1, + installedPowerPerUnitKw: 0.2, + demandFactor: 0.8, + }; + + const result = applyLinkedProjectDeviceValues(input, { + displayName: "Kaffeemaschine", + category: "Küche", + quantity: 3, + installedPowerPerUnitKw: 1.5, + demandFactor: 0.6, + phaseCount: 3, + powerFactor: 0.9, + note: "aus Vorlage", + }); + + assert.equal(result.name, "Kaffeemaschine"); + assert.equal(result.category, "Küche"); + assert.equal(result.quantity, 3); + assert.equal(result.installedPowerPerUnitKw, 1.5); + assert.equal(result.demandFactor, 0.6); + assert.equal(result.phaseCount, 3); + assert.equal(result.powerFactor, 0.9); + assert.equal(result.note, "aus Vorlage"); + }); + + it("keeps values unchanged when entry is not linked", () => { + const input = { + projectId: "p1", + projectDeviceId: "d1", + isLinkedToDevice: false, + name: "Eigener Name", + quantity: 2, + installedPowerPerUnitKw: 0.5, + demandFactor: 1, + }; + + const result = applyLinkedProjectDeviceValues(input, { + displayName: "Vorlage", + category: "Test", + quantity: 9, + installedPowerPerUnitKw: 9, + demandFactor: 0.2, + phaseCount: 1, + powerFactor: 1, + note: null, + }); + + assert.equal(result.name, "Eigener Name"); + assert.equal(result.quantity, 2); + assert.equal(result.installedPowerPerUnitKw, 0.5); + assert.equal(result.demandFactor, 1); + }); +}); diff --git a/tests/consumer-schema-options.test.ts b/tests/consumer-schema-options.test.ts new file mode 100644 index 0000000..6ab808a --- /dev/null +++ b/tests/consumer-schema-options.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createConsumerSchema } from "../src/shared/validation/consumer.schemas.js"; + +describe("consumer schema fixed option lists", () => { + it("accepts valid predefined domain values", () => { + const parsed = createConsumerSchema.safeParse({ + projectId: "p1", + name: "Test", + quantity: 1, + installedPowerPerUnitKw: 1, + demandFactor: 1, + deviceType: "Beleuchtung", + phaseType: "3-phasig", + tradeOrCostGroup: "KG 440 Starkstromanlagen", + group: "Technik", + protectionType: "LS", + protectionCharacteristic: "C", + cableType: "NYM-J", + cableCrossSection: "2,5 mm²", + }); + + assert.equal(parsed.success, true); + }); + + it("rejects unknown domain values", () => { + const parsed = createConsumerSchema.safeParse({ + projectId: "p1", + name: "Test", + quantity: 1, + installedPowerPerUnitKw: 1, + demandFactor: 1, + deviceType: "Irgendwas", + }); + + assert.equal(parsed.success, false); + }); +});
BezeichnungInterner NameAnzeigename Kategorie Anzahl Leistung je Stück [kW] GZFAktionAktionen
+ + event.target.value !== device.displayName + ? handleQuickUpdateProjectDevice(device, "displayName", event.target.value) + : undefined + } + /> + {device.installedPowerPerUnitKw} {device.demandFactor} - +
+ + +
+ Noch keine Projektgeräte vorhanden.
BezeichnungInterner NameAnzeigename Kategorie Anzahl Leistung je Stück [kW] + + event.target.value !== device.displayName + ? handleQuickUpdateGlobalDevice(device, "displayName", event.target.value) + : undefined + } + /> + - + Noch keine globalen Geräte vorhanden.