From 65819900b1e64f5252f5787882c32a9050286b81 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 1 May 2026 17:07:56 +0200 Subject: [PATCH] Rewrite frontend, added rooms, voltage selection per project, startet with todos --- AGENTS.md | 66 + package-lock.json | 29 + package.json | 1 + src/app/globals.css | 331 +---- src/app/layout.tsx | 3 +- src/app/page.tsx | 4 +- .../[projectId]/circuit-lists/page.tsx | 1121 +++++++++++++++++ src/app/projects/[projectId]/page.tsx | 660 ++++++++++ src/app/projects/page.tsx | 378 ++++++ src/db/migrations/0001_global_devices.sql | 12 + .../0002_project_voltage_defaults.sql | 3 + .../migrations/0003_project_floors_rooms.sql | 19 + .../0004_circuit_lists_and_entry_fields.sql | 44 + src/db/migrations/0005_project_devices.sql | 14 + src/db/migrations/meta/_journal.json | 37 +- .../repositories/circuit-list.repository.ts | 56 + src/db/repositories/consumer.repository.ts | 28 + src/db/repositories/floor.repository.ts | 36 + .../repositories/global-device.repository.ts | 57 + .../repositories/project-device.repository.ts | 70 + src/db/repositories/project.repository.ts | 32 +- src/db/repositories/room.repository.ts | 42 + src/db/schema/circuit-lists.ts | 18 + src/db/schema/consumers.ts | 21 +- src/db/schema/floors.ts | 11 + src/db/schema/global-devices.ts | 14 + src/db/schema/project-devices.ts | 18 + src/db/schema/projects.ts | 5 +- src/db/schema/rooms.ts | 13 + src/domain/models/consumer.model.ts | 19 +- src/domain/services/power-balance.service.ts | 25 +- src/frontend/types.ts | 118 ++ src/frontend/utils/api.ts | 112 +- .../controllers/circuit-list.controller.ts | 14 + src/server/controllers/consumer.controller.ts | 299 ++++- .../distribution-board.controller.ts | 7 + src/server/controllers/floor.controller.ts | 30 + .../controllers/global-device.controller.ts | 52 + .../controllers/project-device.controller.ts | 59 + src/server/controllers/project.controller.ts | 37 +- src/server/controllers/room.controller.ts | 39 + src/server/index.ts | 5 +- src/server/routes/global-device.routes.ts | 14 + src/server/routes/project-device.routes.ts | 14 + src/server/routes/project.routes.ts | 17 +- src/shared/types/power.types.ts | 15 +- src/shared/validation/consumer.schemas.ts | 34 + .../validation/global-device.schemas.ts | 18 + .../validation/project-device.schemas.ts | 18 + 49 files changed, 3695 insertions(+), 394 deletions(-) create mode 100644 src/app/projects/[projectId]/circuit-lists/page.tsx create mode 100644 src/app/projects/[projectId]/page.tsx create mode 100644 src/app/projects/page.tsx create mode 100644 src/db/migrations/0001_global_devices.sql create mode 100644 src/db/migrations/0002_project_voltage_defaults.sql create mode 100644 src/db/migrations/0003_project_floors_rooms.sql create mode 100644 src/db/migrations/0004_circuit_lists_and_entry_fields.sql create mode 100644 src/db/migrations/0005_project_devices.sql create mode 100644 src/db/repositories/circuit-list.repository.ts create mode 100644 src/db/repositories/floor.repository.ts create mode 100644 src/db/repositories/global-device.repository.ts create mode 100644 src/db/repositories/project-device.repository.ts create mode 100644 src/db/repositories/room.repository.ts create mode 100644 src/db/schema/circuit-lists.ts create mode 100644 src/db/schema/floors.ts create mode 100644 src/db/schema/global-devices.ts create mode 100644 src/db/schema/project-devices.ts create mode 100644 src/db/schema/rooms.ts create mode 100644 src/server/controllers/circuit-list.controller.ts create mode 100644 src/server/controllers/floor.controller.ts create mode 100644 src/server/controllers/global-device.controller.ts create mode 100644 src/server/controllers/project-device.controller.ts create mode 100644 src/server/controllers/room.controller.ts create mode 100644 src/server/routes/global-device.routes.ts create mode 100644 src/server/routes/project-device.routes.ts create mode 100644 src/shared/validation/global-device.schemas.ts create mode 100644 src/shared/validation/project-device.schemas.ts diff --git a/AGENTS.md b/AGENTS.md index c55d6d1..16f7b05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,20 @@ Document: Prefer concise Markdown documentation. +## Development Progress Documentation + +Document the current implementation status in dedicated Markdown files whenever regular status updates are requested. + +- Keep status tracking explicit with `TODO`, `WIP`, and `DONE` sections. +- For each relevant module or feature area, document: + - what it does, + - how it works, + - which files/functions are involved, + - why important design choices were made, + - current limitations or open points. +- Break larger features into small sub-sections so someone new can read through and understand the flow. +- Update these Markdown status documents incrementally during implementation so progress can be tracked clearly over time. +- Prefer understandable, step-by-step explanations over overly brief summaries when needed for clarity. ## Coding Style Write clear, explicit TypeScript. @@ -397,3 +411,55 @@ The first useful milestone should be: - Provide basic tests for calculation logic Do not start with advanced reporting before the core data and calculation workflow works. + +## Current UI Workflow Requirements + +Implement and preserve the following navigation and workflow structure: + +1. A dedicated project page that lists all projects and allows creating new projects. +2. On the same project page, users can configure global devices/consumers. +3. Inside a project, users first see all distribution boards and can open a selected board. +4. Circuit lists are edited in a dedicated view where up to 3 circuit lists can be opened in parallel. +5. Users must be able to copy circuit entries/consumers between the open circuit lists with minimal clicks. +6. In the circuit-list view, exactly 1 list is open by default. Users can add/remove list panels dynamically with a minimum of 1 and a maximum of 3 open lists. + +When implementing frontend changes, keep this structure as the default interaction model unless the user explicitly requests a different UX. + +## Language and Text Rules + +Use proper German umlauts (ä, ö, ü, Ä, Ö, Ü, ß) in all new or changed German UI texts and documentation text, unless technical constraints explicitly prevent this. + +## Responsiveness Rule + +Frontend implementations must remain fully responsive by default across mobile, tablet, laptop, and wide desktop breakpoints. +Layouts with dynamic panel counts (for example 1-3 parallel circuit-list panels) must adapt so available horizontal space is used appropriately instead of leaving fixed empty columns. + +## Language and Text Rules (Enforced) + +Use proper German umlauts (�, �, �, �, �, �, �) in all new or changed German UI texts and documentation text, unless technical constraints explicitly prevent this. + + +## Project Voltage and Columns Rules + +- Project properties must include default single-phase and three-phase voltages. Use 230 V for single-phase and 400 V for three-phase by default, and allow users to change both values in project settings. +- In circuit lists, when a consumer is single-phase and has no explicit voltage override, calculations use the project single-phase default voltage. When a consumer is three-phase and has no explicit voltage override, calculations use the project three-phase default voltage. +- When creating a new consumer/device entry, the standard input fields should be: consumer display name, quantity, unit power, demand factor, and total power. +- 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. + + +## 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. diff --git a/package-lock.json b/package-lock.json index 5579099..b0abb41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "better-sqlite3": "^12.9.0", + "bootstrap": "^5.3.8", "drizzle-orm": "^0.45.2", "express": "^5.2.1", "lucide-react": "^1.14.0", @@ -1437,6 +1438,16 @@ "node": ">= 10" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1656,6 +1667,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", diff --git a/package.json b/package.json index 7753558..bc7f184 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "license": "ISC", "dependencies": { "better-sqlite3": "^12.9.0", + "bootstrap": "^5.3.8", "drizzle-orm": "^0.45.2", "express": "^5.2.1", "lucide-react": "^1.14.0", diff --git a/src/app/globals.css b/src/app/globals.css index 35f1d21..4b7f0ce 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,331 +1,12 @@ -:root { - color-scheme: light; - --bg: #f4f6f8; - --band: #ffffff; - --line: #d7dde5; - --line-strong: #b9c3d0; - --text: #17212f; - --muted: #647084; - --accent: #0f766e; - --accent-dark: #115e59; - --warn-bg: #fff4e5; - --warn-line: #f3b562; -} - -* { - box-sizing: border-box; -} - body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: Arial, Helvetica, sans-serif; + background-color: #f5f7fb; } -button, -input, -select { - font: inherit; +.card { + border-radius: 0.5rem; } -.workspace { - min-height: 100vh; - padding: 20px; - display: grid; - gap: 14px; -} - -.topbar, -.toolbarBand, -.entryBand, -.tableBand { - background: var(--band); - border: 1px solid var(--line); -} - -.topbar { - min-height: 82px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 18px; -} - -.eyebrow { - margin: 0 0 4px; - color: var(--muted); - font-size: 12px; - text-transform: uppercase; -} - -h1, -h2 { - margin: 0; - letter-spacing: 0; -} - -h1 { - font-size: 28px; -} - -h2 { - font-size: 19px; -} - -.iconButton, -.primaryButton { - border: 1px solid var(--accent); - background: var(--accent); - color: #ffffff; - min-height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - cursor: pointer; -} - -.iconButton { - width: 38px; - border-radius: 6px; -} - -.iconButton.small { - width: 30px; - min-height: 30px; -} - -.iconButton.muted { - background: #64748b; - border-color: #64748b; -} - -.iconButton.danger { - background: #b42318; - border-color: #b42318; -} - -.primaryButton { - border-radius: 6px; - padding: 0 12px; - white-space: nowrap; -} - -.primaryButton:hover, -.iconButton:hover { - background: var(--accent-dark); -} - -.primaryButton:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.alert { - margin: 0; - padding: 10px 12px; - border: 1px solid var(--warn-line); - background: var(--warn-bg); -} - -.toolbarBand { - display: grid; - grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr) minmax(320px, 0.9fr); - gap: 14px; - padding: 14px; -} - -.projectForm, -.boardForm, -.consumerForm { - display: grid; - gap: 10px; - align-items: end; -} - -.projectForm, -.boardForm { - grid-template-columns: minmax(180px, 1fr) minmax(220px, 1fr) auto; -} - -.consumerForm { - grid-template-columns: minmax(210px, 1.6fr) minmax(150px, 1fr) 90px 140px 150px 110px 110px 100px minmax(180px, 1.4fr) auto; -} - -label { - min-width: 0; - display: grid; - gap: 5px; - color: var(--muted); - font-size: 12px; -} - -input, -select { - width: 100%; - min-height: 36px; - border: 1px solid var(--line-strong); - border-radius: 6px; - padding: 6px 9px; - color: var(--text); - background: #ffffff; -} - -.summaryStrip { - display: grid; - grid-template-columns: repeat(3, minmax(120px, 1fr)); - border: 1px solid var(--line); -} - -.summaryStrip div { - padding: 11px 12px; - border-right: 1px solid var(--line); - display: grid; - gap: 4px; -} - -.summaryStrip div:last-child { - border-right: 0; -} - -.summaryStrip span, -.statusPill { - color: var(--muted); - font-size: 12px; -} - -.summaryStrip strong { - font-size: 18px; -} - -.entryBand, -.tableBand { - padding: 14px; -} - -.tableHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - margin-bottom: 12px; -} - -.boardTotals { - margin-bottom: 14px; -} - -.boardTotals h3 { - margin: 0 0 8px; - font-size: 15px; - color: #344054; -} - -.totalRow td { - font-weight: 700; - background: #eef4f7; -} - -.subline { - margin: 6px 0 0; - color: var(--muted); - font-size: 13px; -} - -.statusPill, -.nameCell { - display: inline-flex; - align-items: center; - gap: 7px; -} - -.statusPill { - min-height: 30px; - border: 1px solid var(--line); - border-radius: 6px; - padding: 0 9px; -} - -.tableScroll { - overflow-x: auto; - border: 1px solid var(--line); -} - -table { - width: 100%; - min-width: 1020px; - border-collapse: collapse; - font-size: 13px; -} - -th, -td { - border-bottom: 1px solid var(--line); - padding: 9px 10px; - text-align: left; - vertical-align: middle; -} - -th { - background: #edf1f5; - color: #344054; - font-weight: 700; -} - -tbody tr:hover { - background: #f8fafc; -} - -.rowField { - display: grid; - grid-template-columns: 1fr 64px 1fr; - gap: 6px; -} - -.rowActions { - display: flex; - gap: 6px; - justify-content: flex-end; -} - -.emptyState { - height: 92px; - color: var(--muted); - text-align: center; -} - -@media (max-width: 1180px) { - .toolbarBand, - .projectForm, - .boardForm, - .consumerForm { - grid-template-columns: 1fr 1fr; - } - - .summaryStrip, - .wideField, - .consumerForm .primaryButton { - grid-column: 1 / -1; - } -} - -@media (max-width: 720px) { - .workspace { - padding: 10px; - } - - .topbar, - .tableHeader { - align-items: flex-start; - flex-direction: column; - } - - .toolbarBand, - .projectForm, - .boardForm, - .consumerForm, - .summaryStrip { - grid-template-columns: 1fr; - } +.table td input.form-control-sm, +.table td select.form-select-sm { + min-width: 8rem; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a86c588..a67de86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; +import "bootstrap/dist/css/bootstrap.min.css"; import "./globals.css"; export const metadata: Metadata = { title: "Leistungsbilanz", - description: "Leistungsbilanz fuer elektrische Verbraucher", + description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten", }; export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { diff --git a/src/app/page.tsx b/src/app/page.tsx index a6839d3..10f2e4e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ -import { PowerBalanceWorkspace } from "../frontend/components/power-balance-workspace"; +import { redirect } from "next/navigation"; export default function Home() { - return ; + redirect("/projects"); } diff --git a/src/app/projects/[projectId]/circuit-lists/page.tsx b/src/app/projects/[projectId]/circuit-lists/page.tsx new file mode 100644 index 0000000..b909e56 --- /dev/null +++ b/src/app/projects/[projectId]/circuit-lists/page.tsx @@ -0,0 +1,1121 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useSearchParams } from "next/navigation"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + createConsumer, + deleteConsumer, + getProject, + listConsumers, + listDistributionBoards, + listProjectDevices, + listRooms, + updateConsumer, + updateProjectSettings, +} from "../../../../frontend/utils/api"; +import type { + ConsumerWithCalculatedValues, + CreateConsumerInput, + DistributionBoardDto, + ProjectDeviceDto, + ProjectDto, + RoomDto, +} from "../../../../frontend/types"; + +interface SlotState { + boardId: string; + selectedConsumerIds: string[]; +} + +interface QuickCreateFormState { + name: string; + roomId: string; + quantity: string; + installedPowerPerUnitKw: string; + demandFactor: string; + totalPowerKw: string; +} + +type ColumnKey = + | "selection" + | "circuitNumber" + | "description" + | "name" + | "room" + | "floor" + | "category" + | "quantity" + | "installedPowerPerUnitKw" + | "installedPowerKw" + | "demandFactor" + | "demandPowerKw" + | "voltageV" + | "phaseCount" + | "powerFactor" + | "currentA" + | "note" + | "actions"; + +const defaultSlots: SlotState[] = [ + { boardId: "", selectedConsumerIds: [] }, + { boardId: "", selectedConsumerIds: [] }, + { boardId: "", selectedConsumerIds: [] }, +]; + +const defaultCreateForm: QuickCreateFormState = { + name: "", + roomId: "", + quantity: "1", + installedPowerPerUnitKw: "0.10", + demandFactor: "1.00", + totalPowerKw: "0.10", +}; + +const allColumns: Array<{ key: ColumnKey; label: string }> = [ + { key: "selection", label: "Auswahl" }, + { key: "circuitNumber", label: "Stromkreis-Nr." }, + { key: "description", label: "Beschreibung" }, + { key: "name", label: "Verbraucher" }, + { key: "room", label: "Raum" }, + { key: "floor", label: "Etage" }, + { key: "category", label: "Kategorie" }, + { key: "quantity", label: "Anzahl" }, + { key: "installedPowerPerUnitKw", label: "Einzelleistung [kW]" }, + { key: "installedPowerKw", label: "Installierte Leistung [kW]" }, + { key: "demandFactor", label: "Gleichzeitigkeitsfaktor" }, + { key: "demandPowerKw", label: "Gesamtleistung [kW]" }, + { key: "voltageV", label: "Spannung [V]" }, + { key: "phaseCount", label: "Phasen" }, + { key: "powerFactor", label: "cos φ" }, + { key: "currentA", label: "Strom [A]" }, + { key: "note", label: "Bemerkung" }, + { key: "actions", label: "Aktionen" }, +]; + +const defaultVisibleColumns: ColumnKey[] = [ + "selection", + "circuitNumber", + "description", + "name", + "room", + "quantity", + "installedPowerPerUnitKw", + "demandFactor", + "demandPowerKw", + "actions", +]; + +function formatNumber(value: number | undefined, digits = 2) { + if (value === undefined || Number.isNaN(value)) { + return "-"; + } + return value.toFixed(digits); +} + +function roomLabel(room: RoomDto) { + return `${room.roomNumber} - ${room.roomName}`; +} + +export default function CircuitListsPage() { + const params = useParams<{ projectId: string }>(); + const searchParams = useSearchParams(); + const [projectId, setProjectId] = useState(""); + const [project, setProject] = useState(null); + const [boards, setBoards] = useState([]); + const [rooms, setRooms] = useState([]); + const [projectDevices, setProjectDevices] = useState([]); + const [consumers, setConsumers] = useState([]); + const [slots, setSlots] = useState(defaultSlots); + const [activeListCount, setActiveListCount] = useState(1); + const [newDeviceTemplateId, setNewDeviceTemplateId] = useState>({ + 0: "", + 1: "", + 2: "", + }); + const [quickCreateForms, setQuickCreateForms] = useState>({ + 0: { ...defaultCreateForm }, + 1: { ...defaultCreateForm }, + 2: { ...defaultCreateForm }, + }); + const [visibleColumns, setVisibleColumns] = useState(defaultVisibleColumns); + const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState("230"); + const [threePhaseVoltageV, setThreePhaseVoltageV] = useState("400"); + const [showColumnManager, setShowColumnManager] = useState(false); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + setProjectId(params.projectId); + }, [params.projectId]); + + useEffect(() => { + if (!projectId) { + return; + } + Promise.all([ + getProject(projectId), + listDistributionBoards(projectId), + listRooms(projectId), + listProjectDevices(projectId), + listConsumers(projectId), + ]) + .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); + setConsumers(loadedConsumers); + setSlots((current) => [ + { ...current[0], boardId: current[0].boardId || initialBoardId }, + current[1], + current[2], + ]); + setActiveListCount(1); + setError(null); + }) + .catch((err: unknown) => + setError(err instanceof Error ? err.message : "Stromkreislisten konnten nicht geladen werden.") + ); + }, [projectId, searchParams]); + + const boardById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]); + const slotColumnClass = + activeListCount === 1 ? "col-12" : activeListCount === 2 ? "col-12 col-xxl-6" : "col-12 col-xxl-4"; + + function consumersForBoard(boardId: string) { + return consumers.filter((item) => item.distributionBoardId === boardId); + } + + function setSlotBoard(slotIndex: number, boardId: string) { + setSlots((current) => + current.map((slot, index) => (index === slotIndex ? { boardId, selectedConsumerIds: [] } : slot)) + ); + } + + function addList() { + setActiveListCount((current) => Math.min(3, current + 1)); + } + + function removeList() { + setActiveListCount((current) => { + const next = Math.max(1, current - 1); + setSlots((slotState) => + slotState.map((slot, index) => (index >= next ? { boardId: "", selectedConsumerIds: [] } : slot)) + ); + return next; + }); + } + + function toggleConsumerSelection(slotIndex: number, consumerId: string) { + setSlots((current) => + current.map((slot, index) => { + if (index !== slotIndex) { + return slot; + } + const exists = slot.selectedConsumerIds.includes(consumerId); + return { + ...slot, + selectedConsumerIds: exists + ? slot.selectedConsumerIds.filter((id) => id !== consumerId) + : [...slot.selectedConsumerIds, consumerId], + }; + }) + ); + } + + function updateQuickCreateForm(slotIndex: number, patch: Partial) { + setQuickCreateForms((current) => { + const next = { ...current[slotIndex], ...patch }; + const quantity = Number(next.quantity); + const unit = Number(next.installedPowerPerUnitKw); + const demandFactor = Number(next.demandFactor); + if (!Number.isNaN(quantity) && !Number.isNaN(unit) && !Number.isNaN(demandFactor)) { + next.totalPowerKw = String(quantity * unit * demandFactor); + } + return { ...current, [slotIndex]: next }; + }); + } + + function updateTotalPower(slotIndex: number, totalPowerKw: string) { + setQuickCreateForms((current) => { + const next = { ...current[slotIndex], totalPowerKw }; + const quantity = Number(next.quantity); + const demandFactor = Number(next.demandFactor); + const total = Number(totalPowerKw); + if ( + !Number.isNaN(quantity) && + !Number.isNaN(demandFactor) && + !Number.isNaN(total) && + quantity > 0 && + demandFactor > 0 + ) { + next.installedPowerPerUnitKw = String(total / (quantity * demandFactor)); + } + return { ...current, [slotIndex]: next }; + }); + } + + async function reloadConsumers() { + if (!projectId) { + return; + } + 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]; + const form = quickCreateForms[slotIndex]; + const name = form.name.trim(); + if (!projectId || !slot.boardId || !name) { + return; + } + + const payload: CreateConsumerInput = { + projectId, + distributionBoardId: slot.boardId, + roomId: form.roomId || undefined, + description: name, + name, + quantity: Number(form.quantity), + installedPowerPerUnitKw: Number(form.installedPowerPerUnitKw), + demandFactor: Number(form.demandFactor), + phaseCount: 1, + powerFactor: 1, + }; + + setIsSaving(true); + setError(null); + try { + await createConsumer(payload); + setQuickCreateForms((current) => ({ + ...current, + [slotIndex]: { ...defaultCreateForm, roomId: current[slotIndex].roomId }, + })); + await reloadConsumers(); + } catch (err) { + setError(err instanceof Error ? err.message : "Verbraucher konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateFromProjectDevice(slotIndex: number) { + const slot = slots[slotIndex]; + const form = quickCreateForms[slotIndex]; + const templateId = newDeviceTemplateId[slotIndex]; + const projectDevice = projectDevices.find((item) => item.id === templateId); + if (!projectId || !slot.boardId || !projectDevice) { + return; + } + + const payload: CreateConsumerInput = { + projectId, + distributionBoardId: slot.boardId, + roomId: form.roomId || undefined, + description: projectDevice.name, + name: projectDevice.name, + category: projectDevice.category ?? undefined, + quantity: projectDevice.quantity, + installedPowerPerUnitKw: projectDevice.installedPowerPerUnitKw, + demandFactor: projectDevice.demandFactor, + phaseCount: projectDevice.phaseCount ?? 1, + powerFactor: projectDevice.powerFactor ?? 1, + note: projectDevice.note ?? undefined, + }; + + setIsSaving(true); + setError(null); + try { + await createConsumer(payload); + await reloadConsumers(); + } catch (err) { + setError(err instanceof Error ? err.message : "Verbraucher konnte nicht aus Projektgerät erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCopyToSlot(sourceConsumer: ConsumerWithCalculatedValues, targetSlotIndex: number) { + const targetBoardId = slots[targetSlotIndex].boardId; + if (!projectId || !targetBoardId) { + return; + } + + const payload: CreateConsumerInput = { + projectId, + distributionBoardId: targetBoardId, + 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 : "Kopieren in Ziel-Stromkreisliste fehlgeschlagen."); + } finally { + setIsSaving(false); + } + } + + async function handleCopySelectionToSlot(sourceSlotIndex: number, targetSlotIndex: number) { + const sourceSlot = slots[sourceSlotIndex]; + const targetBoardId = slots[targetSlotIndex].boardId; + if (!projectId || !targetBoardId || !sourceSlot.selectedConsumerIds.length) { + return; + } + + const selected = consumers.filter((consumer) => sourceSlot.selectedConsumerIds.includes(consumer.id)); + if (!selected.length) { + return; + } + + setIsSaving(true); + setError(null); + try { + await Promise.all( + selected.map((sourceConsumer) => + createConsumer({ + projectId, + distributionBoardId: targetBoardId, + 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, + }) + ) + ); + await reloadConsumers(); + setSlots((current) => + current.map((slot, index) => (index === sourceSlotIndex ? { ...slot, selectedConsumerIds: [] } : slot)) + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Sammel-Kopieren fehlgeschlagen."); + } finally { + setIsSaving(false); + } + } + + async function handleInlineUpdateFields( + consumer: ConsumerWithCalculatedValues, + patch: Partial + ) { + const payload = { + projectId, + distributionBoardId: + patch.distributionBoardId !== undefined + ? patch.distributionBoardId + : consumer.distributionBoardId ?? undefined, + circuitListId: patch.circuitListId !== undefined ? patch.circuitListId : consumer.circuitListId ?? undefined, + 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, + name: patch.name ?? consumer.name, + category: patch.category !== undefined ? patch.category : consumer.category ?? undefined, + deviceType: patch.deviceType !== undefined ? patch.deviceType : consumer.deviceType, + phaseType: patch.phaseType !== undefined ? patch.phaseType : consumer.phaseType, + tradeOrCostGroup: + patch.tradeOrCostGroup !== undefined ? patch.tradeOrCostGroup : consumer.tradeOrCostGroup, + group: patch.group !== undefined ? patch.group : consumer.group, + protectionType: patch.protectionType !== undefined ? patch.protectionType : consumer.protectionType, + protectionRatedCurrent: + patch.protectionRatedCurrent !== undefined + ? patch.protectionRatedCurrent + : consumer.protectionRatedCurrent, + protectionCharacteristic: + patch.protectionCharacteristic !== undefined + ? patch.protectionCharacteristic + : consumer.protectionCharacteristic, + cableType: patch.cableType !== undefined ? patch.cableType : consumer.cableType, + cableCrossSection: + patch.cableCrossSection !== undefined ? patch.cableCrossSection : consumer.cableCrossSection, + comment: patch.comment !== undefined ? patch.comment : consumer.comment, + quantity: patch.quantity ?? consumer.quantity, + installedPowerPerUnitKw: patch.installedPowerPerUnitKw ?? consumer.installedPowerPerUnitKw, + demandFactor: patch.demandFactor ?? consumer.demandFactor, + voltageV: patch.voltageV !== undefined ? patch.voltageV : consumer.voltageV, + phaseCount: patch.phaseCount !== undefined ? patch.phaseCount : consumer.phaseCount, + powerFactor: patch.powerFactor !== undefined ? patch.powerFactor : consumer.powerFactor, + note: patch.note !== undefined ? patch.note : consumer.note, + }; + try { + const updated = await updateConsumer(consumer.id, payload); + setConsumers((current) => current.map((item) => (item.id === consumer.id ? updated : item))); + } catch (err) { + setError(err instanceof Error ? err.message : "Aktualisierung fehlgeschlagen."); + } + } + + async function handleDeleteConsumer(consumerId: string) { + setIsSaving(true); + setError(null); + try { + await deleteConsumer(consumerId); + await reloadConsumers(); + } catch (err) { + setError(err instanceof Error ? err.message : "Verbraucher konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + + function toggleColumn(column: ColumnKey) { + if (column === "selection" || column === "actions") { + return; + } + setVisibleColumns((current) => + current.includes(column) ? current.filter((item) => item !== column) : [...current, column] + ); + } + + function moveColumn(column: ColumnKey, direction: -1 | 1) { + setVisibleColumns((current) => { + const index = current.indexOf(column); + if (index < 0) { + return current; + } + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= current.length) { + return current; + } + const clone = [...current]; + const [item] = clone.splice(index, 1); + clone.splice(nextIndex, 0, item); + return clone; + }); + } + + const renderedColumns = allColumns.filter((column) => visibleColumns.includes(column.key)); + + return ( +
+
+
+

Stromkreislisten

+

Projekt: {project?.name ?? projectId}

+
+
+ + + + + Zurück zu Verteilern + +
+
+ +

Aktiv geöffnet: {activeListCount} von maximal 3 Listen.

+ + {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
+
+
+ {allColumns + .filter((column) => column.key !== "selection" && column.key !== "actions") + .map((column) => ( +
+
+ +
+ + +
+
+
+ ))} +
+ + Standardmäßig sind cos φ, Phasen und Strom ausgeblendet. Du kannst sie hier jederzeit einblenden. + +
+
+ ) : null} + +
+ {slots.slice(0, activeListCount).map((slot, slotIndex) => { + const listConsumersForSlot = slot.boardId ? consumersForBoard(slot.boardId) : []; + const quickForm = quickCreateForms[slotIndex]; + + return ( +
+
+
+ Liste {slotIndex + 1} + {listConsumersForSlot.length} +
+
+ + + +
handleCreateManualConsumer(slotIndex, event)}> +
+ updateQuickCreateForm(slotIndex, { name: event.target.value })} + /> +
+
+ +
+
+ updateQuickCreateForm(slotIndex, { quantity: event.target.value })} + placeholder="Anzahl" + /> +
+
+ + updateQuickCreateForm(slotIndex, { installedPowerPerUnitKw: event.target.value }) + } + placeholder="Einzelleistung" + /> +
+
+ updateQuickCreateForm(slotIndex, { demandFactor: event.target.value })} + placeholder="Gleichzeitigkeitsfaktor" + /> +
+
+ updateTotalPower(slotIndex, event.target.value)} + placeholder="Gesamtleistung" + /> +
+
+ +
+
+ +
+ + +
+ +
+ + + + {renderedColumns.map((column) => ( + + ))} + + + + {listConsumersForSlot.map((consumer) => ( + + {renderedColumns.map((column) => { + switch (column.key) { + case "selection": + return ( + + ); + case "circuitNumber": + return ( + + ); + case "description": + return ( + + ); + case "name": + return ( + + ); + case "room": + return ( + + ); + case "floor": + return ; + case "category": + return ( + + ); + case "quantity": + return ( + + ); + case "installedPowerPerUnitKw": + return ( + + ); + case "installedPowerKw": + return ; + case "demandFactor": + return ( + + ); + case "demandPowerKw": + return ; + case "voltageV": + return ; + case "phaseCount": + return ( + + ); + case "powerFactor": + return ( + + ); + case "currentA": + return ; + case "note": + return ( + + ); + case "actions": + return ( + + ); + default: + return null; + } + })} + + ))} + {!listConsumersForSlot.length ? ( + + + + ) : null} + +
{column.label}
+ toggleConsumerSelection(slotIndex, consumer.id)} + /> + + + event.target.value !== (consumer.circuitNumber ?? "") + ? handleInlineUpdateFields(consumer, { + circuitNumber: event.target.value || undefined, + }) + : undefined + } + /> + + + event.target.value !== (consumer.description ?? consumer.name) + ? handleInlineUpdateFields(consumer, { + description: event.target.value || undefined, + }) + : undefined + } + /> + + + event.target.value !== consumer.name + ? handleInlineUpdateFields(consumer, { name: event.target.value }) + : undefined + } + /> + + + {consumer.floorName ?? "-"} + + event.target.value !== (consumer.category ?? "") + ? handleInlineUpdateFields(consumer, { + category: event.target.value || undefined, + }) + : undefined + } + /> + + { + const value = Number(event.target.value); + if (!Number.isNaN(value) && value !== consumer.quantity) { + void handleInlineUpdateFields(consumer, { quantity: value }); + } + }} + /> + + { + const value = Number(event.target.value); + if (!Number.isNaN(value) && value !== consumer.installedPowerPerUnitKw) { + void handleInlineUpdateFields(consumer, { installedPowerPerUnitKw: value }); + } + }} + /> + {formatNumber(consumer.installedPowerKw)} + { + const value = Number(event.target.value); + if (!Number.isNaN(value) && value !== consumer.demandFactor) { + void handleInlineUpdateFields(consumer, { demandFactor: value }); + } + }} + /> + {formatNumber(consumer.demandPowerKw)}{formatNumber(consumer.effectiveVoltageV, 0)} + + + { + const raw = event.target.value.trim(); + const value = raw ? Number(raw) : undefined; + if (raw === "" && consumer.powerFactor !== undefined) { + void handleInlineUpdateFields(consumer, { powerFactor: undefined }); + } else if (!Number.isNaN(value) && value !== consumer.powerFactor) { + void handleInlineUpdateFields(consumer, { powerFactor: value }); + } + }} + /> + {consumer.currentA?.toFixed(2) ?? "-"} + + event.target.value !== (consumer.note ?? "") + ? handleInlineUpdateFields(consumer, { note: event.target.value || undefined }) + : undefined + } + /> + +
+ {slots.slice(0, activeListCount).map((_, targetSlotIndex) => ( + + ))} + +
+
+ Keine Einträge für {slot.boardId ? boardById.get(slot.boardId)?.name : "diese Liste"}. +
+
+ +
+ Ausgewählt: {slot.selectedConsumerIds.length} +
+ {slots.slice(0, activeListCount).map((_, targetSlotIndex) => ( + + ))} +
+
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx new file mode 100644 index 0000000..3458d50 --- /dev/null +++ b/src/app/projects/[projectId]/page.tsx @@ -0,0 +1,660 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + createDistributionBoard, + createFloor, + createProjectDevice, + createRoom, + deleteProjectDevice, + listDistributionBoards, + listFloors, + listProjectDevices, + listProjects, + listRooms, + updateProjectDevice, + updateProjectSettings, +} from "../../../frontend/utils/api"; +import type { + CreateProjectDeviceInput, + DistributionBoardDto, + FloorDto, + ProjectDeviceDto, + ProjectDto, + RoomDto, +} from "../../../frontend/types"; + +const emptyProjectDevice: CreateProjectDeviceInput = { + name: "", + category: "", + quantity: 1, + installedPowerPerUnitKw: 0.1, + demandFactor: 1, + voltageV: 230, + phaseCount: 1, + powerFactor: 1, + note: "", +}; + +function toOptionalNumber(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return Number(trimmed); +} + +export default function ProjectDetailPage() { + const params = useParams<{ projectId: string }>(); + const [projectId, setProjectId] = useState(""); + const [project, setProject] = useState(null); + const [boards, setBoards] = useState([]); + const [floors, setFloors] = useState([]); + const [rooms, setRooms] = useState([]); + const [projectDevices, setProjectDevices] = useState([]); + const [boardName, setBoardName] = useState(""); + const [floorName, setFloorName] = useState(""); + const [roomNumber, setRoomNumber] = useState(""); + const [roomName, setRoomName] = useState(""); + const [roomFloorId, setRoomFloorId] = useState(""); + const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState("230"); + const [threePhaseVoltageV, setThreePhaseVoltageV] = useState("400"); + const [projectDeviceForm, setProjectDeviceForm] = useState>({ + name: "", + category: "", + quantity: "1", + installedPowerPerUnitKw: "0.1", + demandFactor: "1", + voltageV: "230", + phaseCount: "1", + powerFactor: "1", + note: "", + }); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + setProjectId(params.projectId); + }, [params.projectId]); + + useEffect(() => { + if (!projectId) { + return; + } + Promise.all([ + listProjects(), + listDistributionBoards(projectId), + listFloors(projectId), + listRooms(projectId), + listProjectDevices(projectId), + ]) + .then(([projects, distributionBoards, loadedFloors, loadedRooms, loadedProjectDevices]) => { + const currentProject = projects.find((item) => item.id === projectId) ?? null; + setProject(currentProject); + if (currentProject) { + setSinglePhaseVoltageV(String(currentProject.singlePhaseVoltageV)); + setThreePhaseVoltageV(String(currentProject.threePhaseVoltageV)); + } + setBoards(distributionBoards); + setFloors(loadedFloors); + setRooms(loadedRooms); + setProjectDevices(loadedProjectDevices); + setError(null); + }) + .catch((err: unknown) => + setError(err instanceof Error ? err.message : "Projektdaten konnten nicht geladen werden.") + ); + }, [projectId]); + + const boardCount = useMemo(() => boards.length, [boards.length]); + const floorCount = useMemo(() => floors.length, [floors.length]); + const roomCount = useMemo(() => rooms.length, [rooms.length]); + const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); + const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]); + + async function handleCreateBoard(event: FormEvent) { + event.preventDefault(); + if (!projectId || !boardName.trim()) { + return; + } + setIsSaving(true); + setError(null); + try { + const created = await createDistributionBoard(projectId, boardName.trim()); + setBoards((current) => [...current, created]); + setBoardName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateFloor(event: FormEvent) { + event.preventDefault(); + if (!projectId || !floorName.trim()) { + return; + } + setIsSaving(true); + setError(null); + try { + const created = await createFloor(projectId, { name: floorName.trim() }); + setFloors((current) => [...current, created]); + setFloorName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateRoom(event: FormEvent) { + event.preventDefault(); + if (!projectId || !roomNumber.trim() || !roomName.trim()) { + return; + } + setIsSaving(true); + setError(null); + try { + const created = await createRoom(projectId, { + floorId: roomFloorId || undefined, + roomNumber: roomNumber.trim(), + roomName: roomName.trim(), + }); + setRooms((current) => [...current, created]); + setRoomNumber(""); + setRoomName(""); + setRoomFloorId(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleSaveProjectSettings() { + if (!projectId) { + return; + } + setIsSaving(true); + setError(null); + try { + const updated = await updateProjectSettings(projectId, { + singlePhaseVoltageV: Number(singlePhaseVoltageV), + threePhaseVoltageV: Number(threePhaseVoltageV), + }); + setProject(updated); + } catch (err) { + setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateProjectDevice(event: FormEvent) { + event.preventDefault(); + if (!projectId || !projectDeviceForm.name.trim()) { + return; + } + + const payload: CreateProjectDeviceInput = { + name: projectDeviceForm.name.trim(), + category: projectDeviceForm.category.trim() || undefined, + quantity: Number(projectDeviceForm.quantity), + installedPowerPerUnitKw: Number(projectDeviceForm.installedPowerPerUnitKw), + demandFactor: Number(projectDeviceForm.demandFactor), + voltageV: toOptionalNumber(projectDeviceForm.voltageV), + phaseCount: projectDeviceForm.phaseCount === "3" ? 3 : 1, + powerFactor: toOptionalNumber(projectDeviceForm.powerFactor), + note: projectDeviceForm.note.trim() || undefined, + }; + + setIsSaving(true); + setError(null); + try { + const created = await createProjectDevice(projectId, payload); + setProjectDevices((current) => [...current, created]); + setProjectDeviceForm({ + name: emptyProjectDevice.name, + category: emptyProjectDevice.category ?? "", + quantity: String(emptyProjectDevice.quantity), + installedPowerPerUnitKw: String(emptyProjectDevice.installedPowerPerUnitKw), + demandFactor: String(emptyProjectDevice.demandFactor), + voltageV: String(emptyProjectDevice.voltageV ?? ""), + phaseCount: String(emptyProjectDevice.phaseCount ?? 1), + powerFactor: String(emptyProjectDevice.powerFactor ?? ""), + note: emptyProjectDevice.note ?? "", + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteProjectDevice(projectDeviceId: string) { + if (!projectId) { + return; + } + setIsSaving(true); + setError(null); + try { + await deleteProjectDevice(projectId, projectDeviceId); + setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId)); + } catch (err) { + setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + + async function handleQuickUpdateProjectDevice( + device: ProjectDeviceDto, + key: "name" | "category", + value: string + ) { + if (!projectId) { + return; + } + + const payload: CreateProjectDeviceInput = { + name: key === "name" ? value : device.name, + category: key === "category" ? value : device.category ?? undefined, + quantity: device.quantity, + installedPowerPerUnitKw: device.installedPowerPerUnitKw, + demandFactor: device.demandFactor, + voltageV: device.voltageV ?? undefined, + phaseCount: device.phaseCount ?? undefined, + powerFactor: device.powerFactor ?? undefined, + note: device.note ?? undefined, + }; + + try { + const updated = await updateProjectDevice(projectId, device.id, payload); + setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item))); + } catch (err) { + setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden."); + } + } + + return ( +
+
+
+

{project?.name ?? "Projekt"}

+

Verteilerübersicht und Einstieg in die Stromkreislisten

+
+ + Zur Projektseite + +
+ + {error ?
{error}
: null} + +
+
+
+
Projekteigenschaften
+
+
+
+ + setSinglePhaseVoltageV(event.target.value)} + /> +
+
+ + setThreePhaseVoltageV(event.target.value)} + /> +
+
+ +
+
+
+
+
+ +
+
+
Neue Verteilung
+
+
+ setBoardName(event.target.value)} + /> + +
+
+
+
+ +
+
+
+ Alle Verteilungen + {boardCount} +
+
+ + + + + + + + + {boards.map((board) => ( + + + + + ))} + {!boards.length ? ( + + + + ) : null} + +
VerteilungAktionen
{board.name} + + Stromkreisliste öffnen + +
+ Noch keine Verteilungen vorhanden. +
+
+
+
+ +
+
+
+ Etagen + {floorCount} +
+
+
+ setFloorName(event.target.value)} + /> + +
+
+
    + {floors.map((floor) => ( +
  • + {floor.name} +
  • + ))} + {!floors.length ? ( +
  • Noch keine Etagen vorhanden.
  • + ) : null} +
+
+
+ +
+
+
+ Räume + {roomCount} +
+
+
+
+ setRoomNumber(event.target.value)} + /> +
+
+ setRoomName(event.target.value)} + /> +
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + {rooms.map((room) => ( + + + + + + ))} + {!rooms.length ? ( + + + + ) : null} + +
RaumnummerRaumnameEtage
{room.roomNumber}{room.roomName}{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}
+ Noch keine Räume vorhanden. +
+
+
+
+
+ +
+
+ Projektgeräte / Verbraucher + {projectDeviceCount} +
+
+
+
+ + setProjectDeviceForm((current) => ({ ...current, name: event.target.value })) + } + /> +
+
+ + setProjectDeviceForm((current) => ({ ...current, category: event.target.value })) + } + /> +
+
+ + setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value })) + } + /> +
+
+ + setProjectDeviceForm((current) => ({ + ...current, + installedPowerPerUnitKw: event.target.value, + })) + } + /> +
+
+ + setProjectDeviceForm((current) => ({ ...current, demandFactor: event.target.value })) + } + /> +
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + {projectDevices.map((device) => ( + + + + + + + + + ))} + {!projectDevices.length ? ( + + + + ) : null} + +
BezeichnungKategorieAnzahlLeistung je Stück [kW]GZFAktion
+ + event.target.value !== device.name + ? handleQuickUpdateProjectDevice(device, "name", event.target.value) + : undefined + } + /> + + + event.target.value !== (device.category ?? "") + ? handleQuickUpdateProjectDevice(device, "category", event.target.value) + : undefined + } + /> + {device.quantity}{device.installedPowerPerUnitKw}{device.demandFactor} + +
+ Noch keine Projektgeräte vorhanden. +
+
+
+ +
+ + 3 parallele Stromkreislisten öffnen + +
+
+ ); +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx new file mode 100644 index 0000000..e2a8575 --- /dev/null +++ b/src/app/projects/page.tsx @@ -0,0 +1,378 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useEffect, useState } from "react"; +import { + createGlobalDevice, + createProject, + deleteGlobalDevice, + listGlobalDevices, + listProjects, + updateGlobalDevice, +} from "../../frontend/utils/api"; +import type { + CreateGlobalDeviceInput, + GlobalDeviceDto, + ProjectDto, +} from "../../frontend/types"; + +const emptyGlobalDevice: CreateGlobalDeviceInput = { + name: "", + category: "", + quantity: 1, + installedPowerPerUnitKw: 0.1, + demandFactor: 1, + voltageV: 230, + phaseCount: 1, + powerFactor: 1, + note: "", +}; + +function toOptionalNumber(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return Number(trimmed); +} + +export default function ProjectsPage() { + const [projects, setProjects] = useState([]); + const [globalDevices, setGlobalDevices] = useState([]); + const [projectName, setProjectName] = useState(""); + const [globalDeviceForm, setGlobalDeviceForm] = useState>({ + name: "", + category: "", + quantity: "1", + installedPowerPerUnitKw: "0.1", + demandFactor: "1", + voltageV: "230", + phaseCount: "1", + powerFactor: "1", + note: "", + }); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + async function loadData() { + setError(null); + const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]); + setProjects(loadedProjects); + setGlobalDevices(loadedGlobalDevices); + } + + useEffect(() => { + loadData().catch((err: unknown) => + setError(err instanceof Error ? err.message : "Daten konnten nicht geladen werden.") + ); + }, []); + + async function handleCreateProject(event: FormEvent) { + event.preventDefault(); + if (!projectName.trim()) { + return; + } + setIsSaving(true); + setError(null); + try { + const created = await createProject(projectName.trim()); + setProjects((current) => [...current, created]); + setProjectName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateGlobalDevice(event: FormEvent) { + event.preventDefault(); + if (!globalDeviceForm.name.trim()) { + return; + } + const payload: CreateGlobalDeviceInput = { + name: globalDeviceForm.name.trim(), + category: globalDeviceForm.category.trim() || undefined, + quantity: Number(globalDeviceForm.quantity), + installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw), + demandFactor: Number(globalDeviceForm.demandFactor), + voltageV: toOptionalNumber(globalDeviceForm.voltageV), + phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1, + powerFactor: toOptionalNumber(globalDeviceForm.powerFactor), + note: globalDeviceForm.note.trim() || undefined, + }; + setIsSaving(true); + setError(null); + try { + const created = await createGlobalDevice(payload); + setGlobalDevices((current) => [...current, created]); + setGlobalDeviceForm({ + name: emptyGlobalDevice.name, + category: emptyGlobalDevice.category ?? "", + quantity: String(emptyGlobalDevice.quantity), + installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw), + demandFactor: String(emptyGlobalDevice.demandFactor), + voltageV: String(emptyGlobalDevice.voltageV ?? ""), + phaseCount: String(emptyGlobalDevice.phaseCount ?? 1), + powerFactor: String(emptyGlobalDevice.powerFactor ?? ""), + note: emptyGlobalDevice.note ?? "", + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht erstellt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteGlobalDevice(globalDeviceId: string) { + setIsSaving(true); + setError(null); + try { + await deleteGlobalDevice(globalDeviceId); + setGlobalDevices((current) => current.filter((item) => item.id !== globalDeviceId)); + } catch (err) { + setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht gelöscht werden."); + } finally { + setIsSaving(false); + } + } + + async function handleQuickUpdateGlobalDevice( + device: GlobalDeviceDto, + key: "name" | "category", + value: string + ) { + const payload: CreateGlobalDeviceInput = { + name: key === "name" ? value : device.name, + category: key === "category" ? value : device.category ?? undefined, + quantity: device.quantity, + installedPowerPerUnitKw: device.installedPowerPerUnitKw, + demandFactor: device.demandFactor, + voltageV: device.voltageV ?? undefined, + phaseCount: device.phaseCount ?? undefined, + powerFactor: device.powerFactor ?? undefined, + note: device.note ?? undefined, + }; + try { + const updated = await updateGlobalDevice(device.id, payload); + setGlobalDevices((current) => current.map((item) => (item.id === device.id ? updated : item))); + } catch (err) { + setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht aktualisiert werden."); + } + } + + return ( +
+
+
+

Projekte

+

Projektübersicht und globale Geräteverwaltung

+
+
+ + {error ?
{error}
: null} + +
+
+
+
Neues Projekt
+
+
+
+ + setProjectName(event.target.value)} + placeholder="z. B. Neubau Schule" + /> +
+ +
+
+
+
+ +
+
+
Alle Projekte
+
+ + + + + + + + + {projects.map((project) => ( + + + + + ))} + {!projects.length ? ( + + + + ) : null} + +
ProjektAktionen
{project.name} + + Projekt öffnen + +
+ Noch keine Projekte vorhanden. +
+
+
+
+
+ +
+
Globale Geräte / Verbraucher
+
+
+
+ setGlobalDeviceForm((current) => ({ ...current, name: event.target.value }))} + /> +
+
+ + setGlobalDeviceForm((current) => ({ ...current, category: event.target.value })) + } + /> +
+
+ + setGlobalDeviceForm((current) => ({ ...current, quantity: event.target.value })) + } + /> +
+
+ + setGlobalDeviceForm((current) => ({ + ...current, + installedPowerPerUnitKw: event.target.value, + })) + } + /> +
+
+ + setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value })) + } + /> +
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + {globalDevices.map((device) => ( + + + + + + + + + ))} + {!globalDevices.length ? ( + + + + ) : null} + +
BezeichnungKategorieAnzahlLeistung je Stück [kW]GZFAktion
+ + event.target.value !== device.name + ? handleQuickUpdateGlobalDevice(device, "name", event.target.value) + : undefined + } + /> + + + event.target.value !== (device.category ?? "") + ? handleQuickUpdateGlobalDevice(device, "category", event.target.value) + : undefined + } + /> + {device.quantity}{device.installedPowerPerUnitKw}{device.demandFactor} + +
+ Noch keine globalen Geräte vorhanden. +
+
+
+
+ ); +} diff --git a/src/db/migrations/0001_global_devices.sql b/src/db/migrations/0001_global_devices.sql new file mode 100644 index 0000000..8a065b1 --- /dev/null +++ b/src/db/migrations/0001_global_devices.sql @@ -0,0 +1,12 @@ +CREATE TABLE `global_devices` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `category` text, + `quantity` integer NOT NULL, + `installed_power_per_unit_kw` real NOT NULL, + `demand_factor` real NOT NULL, + `voltage_v` real, + `phase_count` integer, + `power_factor` real, + `note` text +); diff --git a/src/db/migrations/0002_project_voltage_defaults.sql b/src/db/migrations/0002_project_voltage_defaults.sql new file mode 100644 index 0000000..a1671c9 --- /dev/null +++ b/src/db/migrations/0002_project_voltage_defaults.sql @@ -0,0 +1,3 @@ +ALTER TABLE `projects` ADD COLUMN `single_phase_voltage_v` integer NOT NULL DEFAULT 230; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `three_phase_voltage_v` integer NOT NULL DEFAULT 400; diff --git a/src/db/migrations/0003_project_floors_rooms.sql b/src/db/migrations/0003_project_floors_rooms.sql new file mode 100644 index 0000000..1cc95e3 --- /dev/null +++ b/src/db/migrations/0003_project_floors_rooms.sql @@ -0,0 +1,19 @@ +CREATE TABLE `floors` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `sort_order` integer NOT NULL DEFAULT 0, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `rooms` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `floor_id` text, + `room_number` text NOT NULL, + `room_name` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `room_id` text REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null; diff --git a/src/db/migrations/0004_circuit_lists_and_entry_fields.sql b/src/db/migrations/0004_circuit_lists_and_entry_fields.sql new file mode 100644 index 0000000..263a255 --- /dev/null +++ b/src/db/migrations/0004_circuit_lists_and_entry_fields.sql @@ -0,0 +1,44 @@ +CREATE TABLE `circuit_lists` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `distribution_board_id` text NOT NULL, + `name` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `circuit_lists_distribution_board_id_unique` ON `circuit_lists` (`distribution_board_id`); +--> statement-breakpoint +INSERT INTO `circuit_lists` (`id`, `project_id`, `distribution_board_id`, `name`) +SELECT `id`, `project_id`, `id`, `name` || ' Stromkreisliste' +FROM `distribution_boards`; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `circuit_list_id` text REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE set null; +--> statement-breakpoint +UPDATE `consumers` SET `circuit_list_id` = `distribution_board_id` WHERE `distribution_board_id` IS NOT NULL; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `circuit_number` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `description` text; +--> statement-breakpoint +UPDATE `consumers` SET `description` = `name` WHERE `description` IS NULL; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `device_type` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `phase_type` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `trade_or_cost_group` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `group_name` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `protection_type` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `protection_rated_current` real; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `protection_characteristic` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `cable_type` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `cable_cross_section` text; +--> statement-breakpoint +ALTER TABLE `consumers` ADD COLUMN `comment` text; diff --git a/src/db/migrations/0005_project_devices.sql b/src/db/migrations/0005_project_devices.sql new file mode 100644 index 0000000..5199be3 --- /dev/null +++ b/src/db/migrations/0005_project_devices.sql @@ -0,0 +1,14 @@ +CREATE TABLE `project_devices` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `category` text, + `quantity` integer NOT NULL, + `installed_power_per_unit_kw` real NOT NULL, + `demand_factor` real NOT NULL, + `voltage_v` real, + `phase_count` integer, + `power_factor` real, + `note` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index a49b9b0..2193b14 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,41 @@ "when": 1777565414148, "tag": "0000_bizarre_colossus", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1777577000000, + "tag": "0001_global_devices", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1777580000000, + "tag": "0002_project_voltage_defaults", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1777589000000, + "tag": "0003_project_floors_rooms", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1777594000000, + "tag": "0004_circuit_lists_and_entry_fields", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1777597000000, + "tag": "0005_project_devices", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/db/repositories/circuit-list.repository.ts b/src/db/repositories/circuit-list.repository.ts new file mode 100644 index 0000000..455280a --- /dev/null +++ b/src/db/repositories/circuit-list.repository.ts @@ -0,0 +1,56 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { circuitLists } from "../schema/circuit-lists.js"; + +export class CircuitListRepository { + async listByProject(projectId: string) { + return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId)); + } + + async createForDistributionBoard(input: { + projectId: string; + distributionBoardId: string; + name: string; + }) { + const entry = { + id: input.distributionBoardId, + projectId: input.projectId, + distributionBoardId: input.distributionBoardId, + name: input.name, + }; + await db.insert(circuitLists).values(entry); + return entry; + } + + async findByDistributionBoardId(projectId: string, distributionBoardId: string) { + const [row] = await db + .select() + .from(circuitLists) + .where( + and( + eq(circuitLists.projectId, projectId), + eq(circuitLists.distributionBoardId, distributionBoardId) + ) + ) + .limit(1); + return row ?? null; + } + + async existsInProject(projectId: string, circuitListId: string) { + const [row] = await db + .select({ id: circuitLists.id }) + .from(circuitLists) + .where(and(eq(circuitLists.projectId, projectId), eq(circuitLists.id, circuitListId))) + .limit(1); + return Boolean(row); + } + + async findById(projectId: string, circuitListId: string) { + const [row] = await db + .select() + .from(circuitLists) + .where(and(eq(circuitLists.projectId, projectId), eq(circuitLists.id, circuitListId))) + .limit(1); + return row ?? null; + } +} diff --git a/src/db/repositories/consumer.repository.ts b/src/db/repositories/consumer.repository.ts index e953ded..d8dc744 100644 --- a/src/db/repositories/consumer.repository.ts +++ b/src/db/repositories/consumer.repository.ts @@ -18,8 +18,22 @@ export class ConsumerRepository { id, projectId: input.projectId, distributionBoardId: input.distributionBoardId ?? null, + circuitListId: input.circuitListId ?? null, + roomId: input.roomId ?? null, + circuitNumber: input.circuitNumber ?? null, + description: input.description ?? null, name: input.name, category: input.category ?? null, + deviceType: input.deviceType ?? null, + phaseType: input.phaseType ?? null, + tradeOrCostGroup: input.tradeOrCostGroup ?? null, + group: input.group ?? null, + protectionType: input.protectionType ?? null, + protectionRatedCurrent: input.protectionRatedCurrent ?? null, + protectionCharacteristic: input.protectionCharacteristic ?? null, + cableType: input.cableType ?? null, + cableCrossSection: input.cableCrossSection ?? null, + comment: input.comment ?? null, quantity: input.quantity, installedPowerPerUnitKw: input.installedPowerPerUnitKw, demandFactor: input.demandFactor, @@ -37,8 +51,22 @@ export class ConsumerRepository { .set({ projectId: input.projectId, distributionBoardId: input.distributionBoardId ?? null, + circuitListId: input.circuitListId ?? null, + roomId: input.roomId ?? null, + circuitNumber: input.circuitNumber ?? null, + description: input.description ?? null, name: input.name, category: input.category ?? null, + deviceType: input.deviceType ?? null, + phaseType: input.phaseType ?? null, + tradeOrCostGroup: input.tradeOrCostGroup ?? null, + group: input.group ?? null, + protectionType: input.protectionType ?? null, + protectionRatedCurrent: input.protectionRatedCurrent ?? null, + protectionCharacteristic: input.protectionCharacteristic ?? null, + cableType: input.cableType ?? null, + cableCrossSection: input.cableCrossSection ?? null, + comment: input.comment ?? null, quantity: input.quantity, installedPowerPerUnitKw: input.installedPowerPerUnitKw, demandFactor: input.demandFactor, diff --git a/src/db/repositories/floor.repository.ts b/src/db/repositories/floor.repository.ts new file mode 100644 index 0000000..23751a2 --- /dev/null +++ b/src/db/repositories/floor.repository.ts @@ -0,0 +1,36 @@ +import crypto from "node:crypto"; +import { and, asc, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { floors } from "../schema/floors.js"; + +export class FloorRepository { + async listByProject(projectId: string) { + return db + .select() + .from(floors) + .where(eq(floors.projectId, projectId)) + .orderBy(asc(floors.sortOrder), asc(floors.name)); + } + + async create(projectId: string, name: string) { + const id = crypto.randomUUID(); + const existing = await this.listByProject(projectId); + const floor = { + id, + projectId, + name, + sortOrder: existing.length, + }; + await db.insert(floors).values(floor); + return floor; + } + + async existsInProject(projectId: string, floorId: string) { + const [row] = await db + .select({ id: floors.id }) + .from(floors) + .where(and(eq(floors.projectId, projectId), eq(floors.id, floorId))) + .limit(1); + return Boolean(row); + } +} diff --git a/src/db/repositories/global-device.repository.ts b/src/db/repositories/global-device.repository.ts new file mode 100644 index 0000000..087bfcf --- /dev/null +++ b/src/db/repositories/global-device.repository.ts @@ -0,0 +1,57 @@ +import crypto from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { globalDevices } from "../schema/global-devices.js"; +import type { + CreateGlobalDeviceInput, + UpdateGlobalDeviceInput, +} from "../../shared/validation/global-device.schemas.js"; + +export class GlobalDeviceRepository { + async list() { + return db.select().from(globalDevices); + } + + async create(input: CreateGlobalDeviceInput) { + const id = crypto.randomUUID(); + await db.insert(globalDevices).values({ + id, + name: input.name, + category: input.category ?? null, + quantity: input.quantity, + installedPowerPerUnitKw: input.installedPowerPerUnitKw, + demandFactor: input.demandFactor, + voltageV: input.voltageV ?? null, + phaseCount: input.phaseCount ?? null, + powerFactor: input.powerFactor ?? null, + note: input.note ?? null, + }); + return { id, ...input }; + } + + async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) { + await db + .update(globalDevices) + .set({ + name: input.name, + category: input.category ?? null, + quantity: input.quantity, + installedPowerPerUnitKw: input.installedPowerPerUnitKw, + demandFactor: input.demandFactor, + voltageV: input.voltageV ?? null, + phaseCount: input.phaseCount ?? null, + powerFactor: input.powerFactor ?? null, + note: input.note ?? null, + }) + .where(eq(globalDevices.id, globalDeviceId)); + } + + async findById(globalDeviceId: string) { + const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1); + return row ?? null; + } + + async delete(globalDeviceId: string) { + await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId)); + } +} diff --git a/src/db/repositories/project-device.repository.ts b/src/db/repositories/project-device.repository.ts new file mode 100644 index 0000000..009f01a --- /dev/null +++ b/src/db/repositories/project-device.repository.ts @@ -0,0 +1,70 @@ +import crypto from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { projectDevices } from "../schema/project-devices.js"; +import type { + CreateProjectDeviceInput, + UpdateProjectDeviceInput, +} from "../../shared/validation/project-device.schemas.js"; + +export class ProjectDeviceRepository { + async listByProject(projectId: string) { + return db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId)); + } + + async create(projectId: string, input: CreateProjectDeviceInput) { + const id = crypto.randomUUID(); + await db.insert(projectDevices).values({ + id, + projectId, + name: input.name, + category: input.category ?? null, + quantity: input.quantity, + installedPowerPerUnitKw: input.installedPowerPerUnitKw, + demandFactor: input.demandFactor, + voltageV: input.voltageV ?? null, + phaseCount: input.phaseCount ?? null, + powerFactor: input.powerFactor ?? null, + note: input.note ?? null, + }); + return { id, projectId, ...input }; + } + + async findById(projectId: string, projectDeviceId: string) { + const [row] = await db + .select() + .from(projectDevices) + .where( + and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId)) + ) + .limit(1); + return row ?? null; + } + + async update(projectId: string, projectDeviceId: string, input: UpdateProjectDeviceInput) { + await db + .update(projectDevices) + .set({ + name: input.name, + category: input.category ?? null, + quantity: input.quantity, + installedPowerPerUnitKw: input.installedPowerPerUnitKw, + demandFactor: input.demandFactor, + voltageV: input.voltageV ?? null, + phaseCount: input.phaseCount ?? null, + powerFactor: input.powerFactor ?? null, + note: input.note ?? null, + }) + .where( + and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId)) + ); + } + + async delete(projectId: string, projectDeviceId: string) { + await db + .delete(projectDevices) + .where( + and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId)) + ); + } +} diff --git a/src/db/repositories/project.repository.ts b/src/db/repositories/project.repository.ts index d2aaa0a..a13f2bc 100644 --- a/src/db/repositories/project.repository.ts +++ b/src/db/repositories/project.repository.ts @@ -2,20 +2,44 @@ import crypto from "node:crypto"; import { eq } from "drizzle-orm"; import { db } from "../client.js"; import { projects } from "../schema/projects.js"; +import type { + CreateProjectInput, + UpdateProjectSettingsInput, +} from "../../shared/validation/consumer.schemas.js"; export class ProjectRepository { async list() { return db.select().from(projects); } - async create(name: string) { + async create(input: CreateProjectInput) { const id = crypto.randomUUID(); - await db.insert(projects).values({ id, name }); - return { id, name }; + const project = { + id, + name: input.name, + singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230, + threePhaseVoltageV: input.threePhaseVoltageV ?? 400, + }; + await db.insert(projects).values(project); + return project; + } + + async findById(projectId: string) { + const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + return row ?? null; + } + + async updateSettings(projectId: string, input: UpdateProjectSettingsInput) { + await db + .update(projects) + .set({ + singlePhaseVoltageV: input.singlePhaseVoltageV, + threePhaseVoltageV: input.threePhaseVoltageV, + }) + .where(eq(projects.id, projectId)); } async delete(projectId: string) { await db.delete(projects).where(eq(projects.id, projectId)); } } - diff --git a/src/db/repositories/room.repository.ts b/src/db/repositories/room.repository.ts new file mode 100644 index 0000000..b71a189 --- /dev/null +++ b/src/db/repositories/room.repository.ts @@ -0,0 +1,42 @@ +import crypto from "node:crypto"; +import { and, asc, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { rooms } from "../schema/rooms.js"; +import type { CreateRoomInput } from "../../shared/validation/consumer.schemas.js"; + +export class RoomRepository { + async listByProject(projectId: string) { + return db + .select() + .from(rooms) + .where(eq(rooms.projectId, projectId)) + .orderBy(asc(rooms.roomNumber), asc(rooms.roomName)); + } + + async create(projectId: string, input: CreateRoomInput) { + const id = crypto.randomUUID(); + const room = { + id, + projectId, + floorId: input.floorId ?? null, + roomNumber: input.roomNumber, + roomName: input.roomName, + }; + await db.insert(rooms).values(room); + return room; + } + + async existsInProject(projectId: string, roomId: string) { + const [row] = await db + .select({ id: rooms.id }) + .from(rooms) + .where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId))) + .limit(1); + return Boolean(row); + } + + async findById(roomId: string) { + const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1); + return row ?? null; + } +} diff --git a/src/db/schema/circuit-lists.ts b/src/db/schema/circuit-lists.ts new file mode 100644 index 0000000..0af5138 --- /dev/null +++ b/src/db/schema/circuit-lists.ts @@ -0,0 +1,18 @@ +import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { distributionBoards } from "./distribution-boards.js"; +import { projects } from "./projects.js"; + +export const circuitLists = sqliteTable( + "circuit_lists", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + distributionBoardId: text("distribution_board_id") + .notNull() + .references(() => distributionBoards.id, { onDelete: "cascade" }), + name: text("name").notNull(), + }, + (table) => [unique("circuit_lists_distribution_board_id_unique").on(table.distributionBoardId)] +); diff --git a/src/db/schema/consumers.ts b/src/db/schema/consumers.ts index c27aff4..8443fc3 100644 --- a/src/db/schema/consumers.ts +++ b/src/db/schema/consumers.ts @@ -1,6 +1,8 @@ import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { circuitLists } from "./circuit-lists.js"; import { distributionBoards } from "./distribution-boards.js"; import { projects } from "./projects.js"; +import { rooms } from "./rooms.js"; export const consumers = sqliteTable("consumers", { id: text("id").primaryKey(), @@ -10,8 +12,26 @@ export const consumers = sqliteTable("consumers", { distributionBoardId: text("distribution_board_id").references(() => distributionBoards.id, { onDelete: "set null", }), + circuitListId: text("circuit_list_id").references(() => circuitLists.id, { + onDelete: "set null", + }), + roomId: text("room_id").references(() => rooms.id, { + onDelete: "set null", + }), + circuitNumber: text("circuit_number"), + description: text("description"), name: text("name").notNull(), category: text("category"), + deviceType: text("device_type"), + phaseType: text("phase_type"), + tradeOrCostGroup: text("trade_or_cost_group"), + group: text("group_name"), + protectionType: text("protection_type"), + protectionRatedCurrent: real("protection_rated_current"), + protectionCharacteristic: text("protection_characteristic"), + cableType: text("cable_type"), + cableCrossSection: text("cable_cross_section"), + comment: text("comment"), quantity: integer("quantity").notNull(), installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(), demandFactor: real("demand_factor").notNull(), @@ -20,4 +40,3 @@ export const consumers = sqliteTable("consumers", { powerFactor: real("power_factor"), note: text("note"), }); - diff --git a/src/db/schema/floors.ts b/src/db/schema/floors.ts new file mode 100644 index 0000000..5c68753 --- /dev/null +++ b/src/db/schema/floors.ts @@ -0,0 +1,11 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +export const floors = sqliteTable("floors", { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + sortOrder: integer("sort_order").notNull().default(0), +}); diff --git a/src/db/schema/global-devices.ts b/src/db/schema/global-devices.ts new file mode 100644 index 0000000..2b47c91 --- /dev/null +++ b/src/db/schema/global-devices.ts @@ -0,0 +1,14 @@ +import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const globalDevices = sqliteTable("global_devices", { + id: text("id").primaryKey(), + name: text("name").notNull(), + category: text("category"), + quantity: integer("quantity").notNull(), + installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(), + demandFactor: real("demand_factor").notNull(), + voltageV: real("voltage_v"), + phaseCount: integer("phase_count"), + powerFactor: real("power_factor"), + note: text("note"), +}); diff --git a/src/db/schema/project-devices.ts b/src/db/schema/project-devices.ts new file mode 100644 index 0000000..c6b5ca3 --- /dev/null +++ b/src/db/schema/project-devices.ts @@ -0,0 +1,18 @@ +import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +export const projectDevices = sqliteTable("project_devices", { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + category: text("category"), + quantity: integer("quantity").notNull(), + installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(), + demandFactor: real("demand_factor").notNull(), + voltageV: real("voltage_v"), + phaseCount: integer("phase_count"), + powerFactor: real("power_factor"), + note: text("note"), +}); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 363149b..8794ba9 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -1,7 +1,8 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const projects = sqliteTable("projects", { id: text("id").primaryKey(), name: text("name").notNull(), + singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230), + threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400), }); - diff --git a/src/db/schema/rooms.ts b/src/db/schema/rooms.ts new file mode 100644 index 0000000..271106d --- /dev/null +++ b/src/db/schema/rooms.ts @@ -0,0 +1,13 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { floors } from "./floors.js"; +import { projects } from "./projects.js"; + +export const rooms = sqliteTable("rooms", { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + floorId: text("floor_id").references(() => floors.id, { onDelete: "set null" }), + roomNumber: text("room_number").notNull(), + roomName: text("room_name").notNull(), +}); diff --git a/src/domain/models/consumer.model.ts b/src/domain/models/consumer.model.ts index e6d1714..8cf2cc7 100644 --- a/src/domain/models/consumer.model.ts +++ b/src/domain/models/consumer.model.ts @@ -2,8 +2,26 @@ export interface Consumer { id: string; projectId: string; distributionBoardId?: string; + circuitListId?: string; + roomId?: string; + roomNumber?: string; + roomName?: string; + floorId?: string; + floorName?: string; + circuitNumber?: string; + description?: string; name: string; category?: string; + deviceType?: string; + phaseType?: string; + tradeOrCostGroup?: string; + group?: string; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + comment?: string; quantity: number; installedPowerPerUnitKw: number; demandFactor: number; @@ -12,4 +30,3 @@ export interface Consumer { powerFactor?: number; note?: string; } - diff --git a/src/domain/services/power-balance.service.ts b/src/domain/services/power-balance.service.ts index 8bcdcc9..1cdf9ca 100644 --- a/src/domain/services/power-balance.service.ts +++ b/src/domain/services/power-balance.service.ts @@ -8,11 +8,20 @@ import type { Consumer } from "../models/consumer.model.js"; export interface ConsumerWithCalculatedValues extends Consumer { installedPowerKw: number; demandPowerKw: number; + effectiveVoltageV?: number; currentA?: number; } +export interface ProjectVoltageDefaults { + singlePhaseVoltageV: number; + threePhaseVoltageV: number; +} + export class PowerBalanceService { - enrichConsumer(consumer: Consumer): ConsumerWithCalculatedValues { + enrichConsumer( + consumer: Consumer, + projectVoltageDefaults?: ProjectVoltageDefaults + ): ConsumerWithCalculatedValues { const installedPowerKw = calculateInstalledPowerKw({ quantity: consumer.quantity, installedPowerPerUnitKw: consumer.installedPowerPerUnitKw, @@ -24,11 +33,19 @@ export class PowerBalanceService { demandFactor: consumer.demandFactor, }); + const effectiveVoltageV = + consumer.voltageV ?? + (consumer.phaseCount === 1 + ? projectVoltageDefaults?.singlePhaseVoltageV + : consumer.phaseCount === 3 + ? projectVoltageDefaults?.threePhaseVoltageV + : undefined); + let currentA: number | undefined; - if (consumer.voltageV && consumer.phaseCount && consumer.powerFactor) { + if (effectiveVoltageV && consumer.phaseCount && consumer.powerFactor) { currentA = calculateCurrentA({ demandPowerKw, - voltageV: consumer.voltageV, + voltageV: effectiveVoltageV, phaseCount: consumer.phaseCount, powerFactor: consumer.powerFactor, }); @@ -38,8 +55,8 @@ export class PowerBalanceService { ...consumer, installedPowerKw, demandPowerKw, + effectiveVoltageV, currentA, }; } } - diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 0c73b1c..7025de6 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -1,14 +1,34 @@ export interface ProjectDto { id: string; name: string; + singlePhaseVoltageV: number; + threePhaseVoltageV: number; } export interface ConsumerWithCalculatedValues { id: string; projectId: string; distributionBoardId?: string | null; + circuitListId?: string | null; + roomId?: string | null; + roomNumber?: string; + roomName?: string; + floorId?: string; + floorName?: string; + circuitNumber?: string; + description?: string; name: string; category?: string; + deviceType?: string; + phaseType?: string; + tradeOrCostGroup?: string; + group?: string; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + comment?: string; quantity: number; installedPowerPerUnitKw: number; demandFactor: number; @@ -18,6 +38,7 @@ export interface ConsumerWithCalculatedValues { note?: string; installedPowerKw: number; demandPowerKw: number; + effectiveVoltageV?: number; currentA?: number; } @@ -27,11 +48,74 @@ export interface DistributionBoardDto { name: string; } +export interface CircuitListDto { + id: string; + projectId: string; + distributionBoardId: string; + name: string; +} + +export interface FloorDto { + id: string; + projectId: string; + name: string; + sortOrder: number; +} + +export interface RoomDto { + id: string; + projectId: string; + floorId: string | null; + roomNumber: string; + roomName: string; +} + +export interface GlobalDeviceDto { + id: string; + name: string; + category: string | null; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV: number | null; + phaseCount: 1 | 3 | null; + powerFactor: number | null; + note: string | null; +} + +export interface ProjectDeviceDto { + id: string; + projectId: string; + name: string; + category: string | null; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV: number | null; + phaseCount: 1 | 3 | null; + powerFactor: number | null; + note: string | null; +} + export interface CreateConsumerInput { projectId: string; distributionBoardId?: string; + circuitListId?: string; + roomId?: string; + circuitNumber?: string; + description?: string; name: string; category?: string; + deviceType?: string; + phaseType?: string; + tradeOrCostGroup?: string; + group?: string; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + comment?: string; quantity: number; installedPowerPerUnitKw: number; demandFactor: number; @@ -42,3 +126,37 @@ export interface CreateConsumerInput { } export interface UpdateConsumerInput extends CreateConsumerInput {} + +export interface CreateFloorInput { + name: string; +} + +export interface CreateRoomInput { + floorId?: string; + roomNumber: string; + roomName: string; +} + +export interface CreateGlobalDeviceInput { + name: string; + category?: string; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV?: number; + phaseCount?: 1 | 3; + powerFactor?: number; + note?: string; +} + +export interface CreateProjectDeviceInput { + name: string; + category?: string; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV?: number; + phaseCount?: 1 | 3; + powerFactor?: number; + note?: string; +} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 675ea8d..1e2bc06 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,8 +1,17 @@ import type { + CircuitListDto, + CreateFloorInput, + CreateProjectDeviceInput, + CreateRoomInput, ConsumerWithCalculatedValues, CreateConsumerInput, + CreateGlobalDeviceInput, DistributionBoardDto, + FloorDto, + GlobalDeviceDto, + ProjectDeviceDto, ProjectDto, + RoomDto, UpdateConsumerInput, } from "../types"; @@ -13,6 +22,7 @@ async function request(url: string, init?: RequestInit): Promise { "Content-Type": "application/json", ...init?.headers, }, + cache: "no-store", }); if (!response.ok) { @@ -20,6 +30,10 @@ async function request(url: string, init?: RequestInit): Promise { throw new Error(details || `Request failed with ${response.status}`); } + if (response.status === 204) { + return undefined as T; + } + return response.json() as Promise; } @@ -27,6 +41,10 @@ export function listProjects() { return request("/api/projects"); } +export function getProject(projectId: string) { + return request(`/api/projects/${projectId}`); +} + export function createProject(name: string) { return request("/api/projects", { method: "POST", @@ -34,6 +52,16 @@ export function createProject(name: string) { }); } +export function updateProjectSettings( + projectId: string, + input: { singlePhaseVoltageV: number; threePhaseVoltageV: number } +) { + return request(`/api/projects/${projectId}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + export function listDistributionBoards(projectId: string) { return request(`/api/projects/${projectId}/distribution-boards`); } @@ -45,6 +73,32 @@ export function createDistributionBoard(projectId: string, name: string) { }); } +export function listCircuitLists(projectId: string) { + return request(`/api/projects/${projectId}/circuit-lists`); +} + +export function listFloors(projectId: string) { + return request(`/api/projects/${projectId}/floors`); +} + +export function createFloor(projectId: string, input: CreateFloorInput) { + return request(`/api/projects/${projectId}/floors`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function listRooms(projectId: string) { + return request(`/api/projects/${projectId}/rooms`); +} + +export function createRoom(projectId: string, input: CreateRoomInput) { + return request(`/api/projects/${projectId}/rooms`, { + method: "POST", + body: JSON.stringify(input), + }); +} + export function listConsumers(projectId: string) { return request(`/api/consumers/projects/${projectId}`); } @@ -63,10 +117,56 @@ export function updateConsumer(consumerId: string, input: UpdateConsumerInput) { }); } -export async function deleteConsumer(consumerId: string) { - const response = await fetch(`/api/consumers/${consumerId}`, { method: "DELETE" }); - if (!response.ok) { - const details = await response.text(); - throw new Error(details || `Request failed with ${response.status}`); - } +export function deleteConsumer(consumerId: string) { + return request(`/api/consumers/${consumerId}`, { method: "DELETE" }); +} + +export function listGlobalDevices() { + return request("/api/global-devices"); +} + +export function createGlobalDevice(input: CreateGlobalDeviceInput) { + return request("/api/global-devices", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateGlobalDevice(globalDeviceId: string, input: CreateGlobalDeviceInput) { + return request(`/api/global-devices/${globalDeviceId}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export function deleteGlobalDevice(globalDeviceId: string) { + return request(`/api/global-devices/${globalDeviceId}`, { method: "DELETE" }); +} + +export function listProjectDevices(projectId: string) { + return request(`/api/project-devices/projects/${projectId}`); +} + +export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) { + return request(`/api/project-devices/projects/${projectId}`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateProjectDevice( + projectId: string, + projectDeviceId: string, + input: CreateProjectDeviceInput +) { + return request(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export function deleteProjectDevice(projectId: string, projectDeviceId: string) { + return request(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, { + method: "DELETE", + }); } diff --git a/src/server/controllers/circuit-list.controller.ts b/src/server/controllers/circuit-list.controller.ts new file mode 100644 index 0000000..a75173e --- /dev/null +++ b/src/server/controllers/circuit-list.controller.ts @@ -0,0 +1,14 @@ +import type { Request, Response } from "express"; +import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; + +const circuitListRepository = new CircuitListRepository(); + +export async function listCircuitListsByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const result = await circuitListRepository.listByProject(projectId); + return res.json(result); +} diff --git a/src/server/controllers/consumer.controller.ts b/src/server/controllers/consumer.controller.ts index f7a8156..7cf78fd 100644 --- a/src/server/controllers/consumer.controller.ts +++ b/src/server/controllers/consumer.controller.ts @@ -1,16 +1,54 @@ import type { Request, Response } from "express"; +import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; 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 { 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 { PowerBalanceService } from "../../domain/services/power-balance.service.js"; import { createConsumerSchema, updateConsumerSchema, } from "../../shared/validation/consumer.schemas.js"; +const circuitListRepository = new CircuitListRepository(); const consumerRepository = new ConsumerRepository(); const distributionBoardRepository = new DistributionBoardRepository(); +const floorRepository = new FloorRepository(); +const projectRepository = new ProjectRepository(); +const roomRepository = new RoomRepository(); const powerBalanceService = new PowerBalanceService(); +type ConsumerRow = { + id: string; + projectId: string; + distributionBoardId: string | null; + circuitListId: string | null; + roomId: string | null; + circuitNumber: string | null; + description: string | null; + name: string; + category: string | null; + deviceType: string | null; + phaseType: string | null; + tradeOrCostGroup: string | null; + group: string | null; + protectionType: string | null; + protectionRatedCurrent: number | null; + protectionCharacteristic: string | null; + cableType: string | null; + cableCrossSection: string | null; + comment: string | null; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV: number | null; + phaseCount: number | null; + powerFactor: number | null; + note: string | null; +}; + async function validateDistributionBoardOwnership( projectId: string, distributionBoardId: string | undefined @@ -21,29 +59,134 @@ async function validateDistributionBoardOwnership( return distributionBoardRepository.existsInProject(projectId, distributionBoardId); } +async function validateRoomOwnership(projectId: string, roomId: string | undefined) { + if (!roomId) { + return true; + } + return roomRepository.existsInProject(projectId, roomId); +} + +async function resolveCircuitScope(input: { + projectId: string; + distributionBoardId?: string; + circuitListId?: string; +}) { + let distributionBoardId = input.distributionBoardId; + let circuitListId = input.circuitListId; + + if (distributionBoardId) { + const linkedList = await circuitListRepository.findByDistributionBoardId( + input.projectId, + distributionBoardId + ); + if (!linkedList) { + return { ok: false as const, error: "No circuit list found for the provided distribution board." }; + } + if (circuitListId && circuitListId !== linkedList.id) { + return { + ok: false as const, + error: "Circuit list does not match the provided distribution board.", + }; + } + circuitListId = linkedList.id; + } + + if (circuitListId) { + const list = await circuitListRepository.findById(input.projectId, circuitListId); + if (!list) { + return { ok: false as const, error: "Circuit list does not belong to the provided project." }; + } + if (distributionBoardId && distributionBoardId !== list.distributionBoardId) { + return { + ok: false as const, + error: "Circuit list does not match the provided distribution board.", + }; + } + distributionBoardId = list.distributionBoardId; + } + + return { + ok: true as const, + distributionBoardId, + circuitListId, + }; +} + +function buildConsumerFromRow( + row: ConsumerRow, + roomById: Map, + floorById: Map +): Consumer { + const room = row.roomId ? roomById.get(row.roomId) : undefined; + const floor = room?.floorId ? floorById.get(room.floorId) : undefined; + + return { + id: row.id, + projectId: row.projectId, + distributionBoardId: row.distributionBoardId ?? undefined, + circuitListId: row.circuitListId ?? undefined, + roomId: row.roomId ?? undefined, + roomNumber: room?.roomNumber, + roomName: room?.roomName, + floorId: room?.floorId ?? undefined, + floorName: floor?.name, + circuitNumber: row.circuitNumber ?? undefined, + description: row.description ?? undefined, + name: row.name, + category: row.category ?? undefined, + deviceType: row.deviceType ?? undefined, + phaseType: row.phaseType ?? undefined, + tradeOrCostGroup: row.tradeOrCostGroup ?? undefined, + group: row.group ?? undefined, + protectionType: row.protectionType ?? undefined, + protectionRatedCurrent: row.protectionRatedCurrent ?? undefined, + protectionCharacteristic: row.protectionCharacteristic ?? undefined, + cableType: row.cableType ?? undefined, + cableCrossSection: row.cableCrossSection ?? undefined, + comment: row.comment ?? undefined, + quantity: row.quantity, + installedPowerPerUnitKw: row.installedPowerPerUnitKw, + demandFactor: row.demandFactor, + voltageV: row.voltageV ?? undefined, + phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined, + powerFactor: row.powerFactor ?? undefined, + note: row.note ?? undefined, + }; +} + export async function listConsumersByProject(req: Request, res: Response) { const { projectId } = req.params; if (typeof projectId !== "string") { return res.status(400).json({ error: "Invalid projectId" }); } - const rows = await consumerRepository.listByProject(projectId); - const enriched = rows.map((row) => - powerBalanceService.enrichConsumer({ - id: row.id, - projectId: row.projectId, - distributionBoardId: row.distributionBoardId ?? undefined, - name: row.name, - category: row.category ?? undefined, - quantity: row.quantity, - installedPowerPerUnitKw: row.installedPowerPerUnitKw, - demandFactor: row.demandFactor, - voltageV: row.voltageV ?? undefined, - phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined, - powerFactor: row.powerFactor ?? undefined, - note: row.note ?? undefined, - }) + + const [rows, project, floors, rooms] = await Promise.all([ + consumerRepository.listByProject(projectId), + projectRepository.findById(projectId), + floorRepository.listByProject(projectId), + roomRepository.listByProject(projectId), + ]); + + const roomById = new Map( + rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }]) ); - res.json(enriched); + const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }])); + + const projectVoltageDefaults = project + ? { + singlePhaseVoltageV: project.singlePhaseVoltageV, + threePhaseVoltageV: project.threePhaseVoltageV, + } + : undefined; + + const enriched = rows.map((row) => + powerBalanceService.enrichConsumer( + buildConsumerFromRow(row as ConsumerRow, roomById, floorById), + projectVoltageDefaults + ) + ); + + return res.json(enriched); } export async function createConsumer(req: Request, res: Response) { @@ -52,18 +195,66 @@ export async function createConsumer(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - const hasValidDistributionBoard = await validateDistributionBoardOwnership( - parsed.data.projectId, - parsed.data.distributionBoardId - ); + const [hasValidDistributionBoard, hasValidRoom] = await Promise.all([ + validateDistributionBoardOwnership(parsed.data.projectId, parsed.data.distributionBoardId), + validateRoomOwnership(parsed.data.projectId, parsed.data.roomId), + ]); if (!hasValidDistributionBoard) { return res .status(400) .json({ error: "Distribution board does not belong to the provided project." }); } + if (!hasValidRoom) { + return res.status(400).json({ error: "Room does not belong to the provided project." }); + } + + const resolvedScope = await resolveCircuitScope({ + projectId: parsed.data.projectId, + distributionBoardId: parsed.data.distributionBoardId, + circuitListId: parsed.data.circuitListId, + }); + if (!resolvedScope.ok) { + return res.status(400).json({ error: resolvedScope.error }); + } + + const payload = { + ...parsed.data, + distributionBoardId: resolvedScope.distributionBoardId, + circuitListId: resolvedScope.circuitListId, + description: parsed.data.description ?? parsed.data.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), + ]); + const roomById = new Map( + rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }]) + ); + const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }])); + + const enriched = powerBalanceService.enrichConsumer( + { + ...(created as Consumer), + description: created.description ?? created.name, + roomNumber: created.roomId ? roomById.get(created.roomId)?.roomNumber : undefined, + roomName: created.roomId ? roomById.get(created.roomId)?.roomName : undefined, + floorId: created.roomId ? roomById.get(created.roomId)?.floorId ?? undefined : undefined, + floorName: + created.roomId && roomById.get(created.roomId)?.floorId + ? floorById.get(roomById.get(created.roomId)!.floorId as string)?.name + : undefined, + }, + project + ? { + singlePhaseVoltageV: project.singlePhaseVoltageV, + threePhaseVoltageV: project.threePhaseVoltageV, + } + : undefined + ); - const created = await consumerRepository.create(parsed.data); - const enriched = powerBalanceService.enrichConsumer(created); return res.status(201).json(enriched); } @@ -78,36 +269,60 @@ export async function updateConsumer(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } - const hasValidDistributionBoard = await validateDistributionBoardOwnership( - parsed.data.projectId, - parsed.data.distributionBoardId - ); + const [hasValidDistributionBoard, hasValidRoom] = await Promise.all([ + validateDistributionBoardOwnership(parsed.data.projectId, parsed.data.distributionBoardId), + validateRoomOwnership(parsed.data.projectId, parsed.data.roomId), + ]); if (!hasValidDistributionBoard) { return res .status(400) .json({ error: "Distribution board does not belong to the provided project." }); } + if (!hasValidRoom) { + return res.status(400).json({ error: "Room does not belong to the provided project." }); + } + + const resolvedScope = await resolveCircuitScope({ + projectId: parsed.data.projectId, + distributionBoardId: parsed.data.distributionBoardId, + circuitListId: parsed.data.circuitListId, + }); + if (!resolvedScope.ok) { + return res.status(400).json({ error: resolvedScope.error }); + } + + await consumerRepository.update(consumerId, { + ...parsed.data, + distributionBoardId: resolvedScope.distributionBoardId, + circuitListId: resolvedScope.circuitListId, + description: parsed.data.description ?? parsed.data.name, + }); - await consumerRepository.update(consumerId, parsed.data); const row = await consumerRepository.findById(consumerId); if (!row) { return res.status(404).json({ error: "Consumer not found" }); } - const enriched = powerBalanceService.enrichConsumer({ - id: row.id, - projectId: row.projectId, - distributionBoardId: row.distributionBoardId ?? undefined, - name: row.name, - category: row.category ?? undefined, - quantity: row.quantity, - installedPowerPerUnitKw: row.installedPowerPerUnitKw, - demandFactor: row.demandFactor, - voltageV: row.voltageV ?? undefined, - phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined, - powerFactor: row.powerFactor ?? undefined, - note: row.note ?? undefined, - }); + const [project, floors, rooms] = await Promise.all([ + projectRepository.findById(row.projectId), + floorRepository.listByProject(row.projectId), + roomRepository.listByProject(row.projectId), + ]); + const roomById = new Map( + rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }]) + ); + const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }])); + + const enriched = powerBalanceService.enrichConsumer( + buildConsumerFromRow(row as ConsumerRow, roomById, floorById), + project + ? { + singlePhaseVoltageV: project.singlePhaseVoltageV, + threePhaseVoltageV: project.threePhaseVoltageV, + } + : undefined + ); + return res.json(enriched); } diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts index 1091443..3c464f9 100644 --- a/src/server/controllers/distribution-board.controller.ts +++ b/src/server/controllers/distribution-board.controller.ts @@ -1,7 +1,9 @@ import type { Request, Response } from "express"; +import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js"; +const circuitListRepository = new CircuitListRepository(); const distributionBoardRepository = new DistributionBoardRepository(); export async function listDistributionBoardsByProject(req: Request, res: Response) { @@ -26,5 +28,10 @@ export async function createDistributionBoard(req: Request, res: Response) { } const board = await distributionBoardRepository.create(projectId, parsed.data.name); + await circuitListRepository.createForDistributionBoard({ + projectId, + distributionBoardId: board.id, + name: `${board.name} Stromkreisliste`, + }); return res.status(201).json(board); } diff --git a/src/server/controllers/floor.controller.ts b/src/server/controllers/floor.controller.ts new file mode 100644 index 0000000..232a068 --- /dev/null +++ b/src/server/controllers/floor.controller.ts @@ -0,0 +1,30 @@ +import type { Request, Response } from "express"; +import { FloorRepository } from "../../db/repositories/floor.repository.js"; +import { createFloorSchema } from "../../shared/validation/consumer.schemas.js"; + +const floorRepository = new FloorRepository(); + +export async function listFloorsByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const result = await floorRepository.listByProject(projectId); + return res.json(result); +} + +export async function createFloor(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const parsed = createFloorSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const floor = await floorRepository.create(projectId, parsed.data.name); + return res.status(201).json(floor); +} diff --git a/src/server/controllers/global-device.controller.ts b/src/server/controllers/global-device.controller.ts new file mode 100644 index 0000000..bc065db --- /dev/null +++ b/src/server/controllers/global-device.controller.ts @@ -0,0 +1,52 @@ +import type { Request, Response } from "express"; +import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; +import { + createGlobalDeviceSchema, + updateGlobalDeviceSchema, +} from "../../shared/validation/global-device.schemas.js"; + +const globalDeviceRepository = new GlobalDeviceRepository(); + +export async function listGlobalDevices(_req: Request, res: Response) { + const rows = await globalDeviceRepository.list(); + return res.json(rows); +} + +export async function createGlobalDevice(req: Request, res: Response) { + const parsed = createGlobalDeviceSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const created = await globalDeviceRepository.create(parsed.data); + return res.status(201).json(created); +} + +export async function updateGlobalDevice(req: Request, res: Response) { + const { globalDeviceId } = req.params; + if (typeof globalDeviceId !== "string") { + return res.status(400).json({ error: "Invalid globalDeviceId" }); + } + + const parsed = updateGlobalDeviceSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + await globalDeviceRepository.update(globalDeviceId, parsed.data); + const row = await globalDeviceRepository.findById(globalDeviceId); + if (!row) { + return res.status(404).json({ error: "Global device not found" }); + } + return res.json(row); +} + +export async function deleteGlobalDevice(req: Request, res: Response) { + const { globalDeviceId } = req.params; + if (typeof globalDeviceId !== "string") { + return res.status(400).json({ error: "Invalid globalDeviceId" }); + } + + await globalDeviceRepository.delete(globalDeviceId); + return res.status(204).send(); +} diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts new file mode 100644 index 0000000..b76baa6 --- /dev/null +++ b/src/server/controllers/project-device.controller.ts @@ -0,0 +1,59 @@ +import type { Request, Response } from "express"; +import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; +import { + createProjectDeviceSchema, + updateProjectDeviceSchema, +} from "../../shared/validation/project-device.schemas.js"; + +const projectDeviceRepository = new ProjectDeviceRepository(); + +export async function listProjectDevicesByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + const rows = await projectDeviceRepository.listByProject(projectId); + return res.json(rows); +} + +export async function createProjectDevice(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + const parsed = createProjectDeviceSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const created = await projectDeviceRepository.create(projectId, parsed.data); + return res.status(201).json(created); +} + +export async function updateProjectDevice(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 parsed = updateProjectDeviceSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data); + const row = await projectDeviceRepository.findById(projectId, projectDeviceId); + if (!row) { + return res.status(404).json({ error: "Project device not found" }); + } + return res.json(row); +} + +export async function deleteProjectDevice(req: Request, res: Response) { + const { projectId, projectDeviceId } = req.params; + if (typeof projectId !== "string" || typeof projectDeviceId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + + await projectDeviceRepository.delete(projectId, projectDeviceId); + return res.status(204).send(); +} diff --git a/src/server/controllers/project.controller.ts b/src/server/controllers/project.controller.ts index 6236f4c..f9739a9 100644 --- a/src/server/controllers/project.controller.ts +++ b/src/server/controllers/project.controller.ts @@ -1,6 +1,9 @@ import type { Request, Response } from "express"; import { ProjectRepository } from "../../db/repositories/project.repository.js"; -import { createProjectSchema } from "../../shared/validation/consumer.schemas.js"; +import { + createProjectSchema, + updateProjectSettingsSchema, +} from "../../shared/validation/consumer.schemas.js"; const projectRepository = new ProjectRepository(); @@ -14,7 +17,37 @@ export async function createProject(req: Request, res: Response) { if (!parsed.success) { return res.status(400).json({ error: parsed.error.flatten() }); } - const project = await projectRepository.create(parsed.data.name); + const project = await projectRepository.create(parsed.data); return res.status(201).json(project); } +export async function getProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + const row = await projectRepository.findById(projectId); + if (!row) { + return res.status(404).json({ error: "Project not found" }); + } + return res.json(row); +} + +export async function updateProjectSettings(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const parsed = updateProjectSettingsSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + await projectRepository.updateSettings(projectId, parsed.data); + const row = await projectRepository.findById(projectId); + if (!row) { + return res.status(404).json({ error: "Project not found" }); + } + return res.json(row); +} diff --git a/src/server/controllers/room.controller.ts b/src/server/controllers/room.controller.ts new file mode 100644 index 0000000..463bb73 --- /dev/null +++ b/src/server/controllers/room.controller.ts @@ -0,0 +1,39 @@ +import type { Request, Response } from "express"; +import { FloorRepository } from "../../db/repositories/floor.repository.js"; +import { RoomRepository } from "../../db/repositories/room.repository.js"; +import { createRoomSchema } from "../../shared/validation/consumer.schemas.js"; + +const floorRepository = new FloorRepository(); +const roomRepository = new RoomRepository(); + +export async function listRoomsByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const result = await roomRepository.listByProject(projectId); + return res.json(result); +} + +export async function createRoom(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const parsed = createRoomSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + if (parsed.data.floorId) { + const hasValidFloor = await floorRepository.existsInProject(projectId, parsed.data.floorId); + if (!hasValidFloor) { + return res.status(400).json({ error: "Floor does not belong to the provided project." }); + } + } + + const room = await roomRepository.create(projectId, parsed.data); + return res.status(201).json(room); +} diff --git a/src/server/index.ts b/src/server/index.ts index ba78b6c..6c82374 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,7 @@ import express from "express"; import { consumerRouter } from "./routes/consumer.routes.js"; +import { globalDeviceRouter } from "./routes/global-device.routes.js"; +import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; @@ -14,10 +16,11 @@ app.get("/health", (_req, res) => { app.use("/api/projects", projectRouter); app.use("/api/consumers", consumerRouter); +app.use("/api/global-devices", globalDeviceRouter); +app.use("/api/project-devices", projectDeviceRouter); app.use(errorMiddleware); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); - diff --git a/src/server/routes/global-device.routes.ts b/src/server/routes/global-device.routes.ts new file mode 100644 index 0000000..775a979 --- /dev/null +++ b/src/server/routes/global-device.routes.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { + createGlobalDevice, + deleteGlobalDevice, + listGlobalDevices, + updateGlobalDevice, +} from "../controllers/global-device.controller.js"; + +export const globalDeviceRouter = Router(); + +globalDeviceRouter.get("/", listGlobalDevices); +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 new file mode 100644 index 0000000..17441fe --- /dev/null +++ b/src/server/routes/project-device.routes.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { + createProjectDevice, + deleteProjectDevice, + listProjectDevicesByProject, + updateProjectDevice, +} from "../controllers/project-device.controller.js"; + +export const projectDeviceRouter = Router(); + +projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject); +projectDeviceRouter.post("/projects/:projectId", createProjectDevice); +projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice); +projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice); diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 6e96df4..f056eda 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -1,13 +1,28 @@ import { Router } from "express"; -import { createProject, listProjects } from "../controllers/project.controller.js"; +import { + createProject, + getProject, + listProjects, + updateProjectSettings, +} from "../controllers/project.controller.js"; import { createDistributionBoard, listDistributionBoardsByProject, } from "../controllers/distribution-board.controller.js"; +import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; +import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js"; +import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; export const projectRouter = Router(); projectRouter.get("/", listProjects); projectRouter.post("/", createProject); +projectRouter.get("/:projectId", getProject); +projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); +projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); +projectRouter.get("/:projectId/floors", listFloorsByProject); +projectRouter.post("/:projectId/floors", createFloor); +projectRouter.get("/:projectId/rooms", listRoomsByProject); +projectRouter.post("/:projectId/rooms", createRoom); diff --git a/src/shared/types/power.types.ts b/src/shared/types/power.types.ts index 1508829..9b3ce4b 100644 --- a/src/shared/types/power.types.ts +++ b/src/shared/types/power.types.ts @@ -13,8 +13,22 @@ export interface ConsumerDto { id: string; projectId: string; distributionBoardId: string | null; + circuitListId: string | null; + roomId: string | null; + circuitNumber: string | null; + description: string | null; name: string; category: string | null; + deviceType: string | null; + phaseType: string | null; + tradeOrCostGroup: string | null; + group: string | null; + protectionType: string | null; + protectionRatedCurrent: number | null; + protectionCharacteristic: string | null; + cableType: string | null; + cableCrossSection: string | null; + comment: string | null; quantity: number; installedPowerPerUnitKw: number; demandFactor: number; @@ -23,4 +37,3 @@ export interface ConsumerDto { powerFactor: number | null; note: string | null; } - diff --git a/src/shared/validation/consumer.schemas.ts b/src/shared/validation/consumer.schemas.ts index 7851c60..4868a92 100644 --- a/src/shared/validation/consumer.schemas.ts +++ b/src/shared/validation/consumer.schemas.ts @@ -3,8 +3,22 @@ import { z } from "zod"; export const createConsumerSchema = z.object({ projectId: z.string().min(1), distributionBoardId: z.string().min(1).optional(), + circuitListId: z.string().min(1).optional(), + roomId: z.string().min(1).optional(), + circuitNumber: z.string().optional(), + description: z.string().optional(), name: z.string().min(1), category: z.string().optional(), + deviceType: z.string().optional(), + phaseType: z.string().optional(), + tradeOrCostGroup: z.string().optional(), + group: z.string().optional(), + protectionType: z.string().optional(), + protectionRatedCurrent: z.number().min(0).optional(), + protectionCharacteristic: z.string().optional(), + cableType: z.string().optional(), + cableCrossSection: z.string().optional(), + comment: z.string().optional(), quantity: z.number().min(0), installedPowerPerUnitKw: z.number().min(0), demandFactor: z.number().min(0).max(1), @@ -18,13 +32,33 @@ export const updateConsumerSchema = createConsumerSchema; export const createProjectSchema = z.object({ name: z.string().min(1), + singlePhaseVoltageV: z.number().positive().optional(), + threePhaseVoltageV: z.number().positive().optional(), +}); + +export const updateProjectSettingsSchema = z.object({ + singlePhaseVoltageV: z.number().positive(), + threePhaseVoltageV: z.number().positive(), }); export const createDistributionBoardSchema = z.object({ name: z.string().min(1), }); +export const createFloorSchema = z.object({ + name: z.string().min(1), +}); + +export const createRoomSchema = z.object({ + floorId: z.string().min(1).optional(), + roomNumber: z.string().min(1), + roomName: z.string().min(1), +}); + export type CreateConsumerInput = z.infer; export type CreateProjectInput = z.infer; +export type UpdateProjectSettingsInput = z.infer; export type CreateDistributionBoardInput = z.infer; export type UpdateConsumerInput = z.infer; +export type CreateFloorInput = z.infer; +export type CreateRoomInput = z.infer; diff --git a/src/shared/validation/global-device.schemas.ts b/src/shared/validation/global-device.schemas.ts new file mode 100644 index 0000000..ade12a4 --- /dev/null +++ b/src/shared/validation/global-device.schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const createGlobalDeviceSchema = z.object({ + name: z.string().min(1), + category: z.string().optional(), + quantity: z.number().min(0), + installedPowerPerUnitKw: z.number().min(0), + demandFactor: z.number().min(0).max(1), + voltageV: z.number().positive().optional(), + phaseCount: z.union([z.literal(1), z.literal(3)]).optional(), + powerFactor: z.number().min(0).max(1).optional(), + note: z.string().optional(), +}); + +export const updateGlobalDeviceSchema = createGlobalDeviceSchema; + +export type CreateGlobalDeviceInput = z.infer; +export type UpdateGlobalDeviceInput = z.infer; diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts new file mode 100644 index 0000000..ad56a71 --- /dev/null +++ b/src/shared/validation/project-device.schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const createProjectDeviceSchema = z.object({ + name: z.string().min(1), + category: z.string().optional(), + quantity: z.number().min(0), + installedPowerPerUnitKw: z.number().min(0), + demandFactor: z.number().min(0).max(1), + voltageV: z.number().positive().optional(), + phaseCount: z.union([z.literal(1), z.literal(3)]).optional(), + powerFactor: z.number().min(0).max(1).optional(), + note: z.string().optional(), +}); + +export const updateProjectDeviceSchema = createProjectDeviceSchema; + +export type CreateProjectDeviceInput = z.infer; +export type UpdateProjectDeviceInput = z.infer;