Rewrite frontend, added rooms, voltage selection per project, startet with todos

This commit is contained in:
2026-05-01 17:07:56 +02:00
parent 81d47ce16f
commit 65819900b1
49 changed files with 3695 additions and 394 deletions
+66
View File
@@ -322,6 +322,20 @@ Document:
Prefer concise Markdown documentation. 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 ## Coding Style
Write clear, explicit TypeScript. Write clear, explicit TypeScript.
@@ -397,3 +411,55 @@ The first useful milestone should be:
- Provide basic tests for calculation logic - Provide basic tests for calculation logic
Do not start with advanced reporting before the core data and calculation workflow works. 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.
+29
View File
@@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"bootstrap": "^5.3.8",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
@@ -1437,6 +1438,16 @@
"node": ">= 10" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1656,6 +1667,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/buffer": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"bootstrap": "^5.3.8",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
+6 -325
View File
@@ -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 { body {
margin: 0; background-color: #f5f7fb;
background: var(--bg);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
} }
button, .card {
input, border-radius: 0.5rem;
select {
font: inherit;
} }
.workspace { .table td input.form-control-sm,
min-height: 100vh; .table td select.form-select-sm {
padding: 20px; min-width: 8rem;
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;
}
} }
+2 -1
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Leistungsbilanz", title: "Leistungsbilanz",
description: "Leistungsbilanz fuer elektrische Verbraucher", description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten",
}; };
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { PowerBalanceWorkspace } from "../frontend/components/power-balance-workspace"; import { redirect } from "next/navigation";
export default function Home() { export default function Home() {
return <PowerBalanceWorkspace />; redirect("/projects");
} }
File diff suppressed because it is too large Load Diff
+660
View File
@@ -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<ProjectDto | null>(null);
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
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<Record<string, string>>({
name: "",
category: "",
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
note: "",
});
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
<p className="text-secondary mb-0">Verteilerübersicht und Einstieg in die Stromkreislisten</p>
</div>
<Link className="btn btn-outline-secondary" href="/projects">
Zur Projektseite
</Link>
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="row g-4">
<section className="col-12">
<div className="card shadow-sm">
<div className="card-header">Projekteigenschaften</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-4">
<label className="form-label">Standardspannung 1-phasig [V]</label>
<input
className="form-control"
type="number"
min="1"
value={singlePhaseVoltageV}
onChange={(event) => setSinglePhaseVoltageV(event.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label">Standardspannung 3-phasig [V]</label>
<input
className="form-control"
type="number"
min="1"
value={threePhaseVoltageV}
onChange={(event) => setThreePhaseVoltageV(event.target.value)}
/>
</div>
<div className="col-12 col-md-4">
<button
className="btn btn-primary w-100"
type="button"
onClick={handleSaveProjectSettings}
disabled={isSaving}
>
Projekteigenschaften speichern
</button>
</div>
</div>
</div>
</div>
</section>
<section className="col-12 col-lg-4">
<div className="card shadow-sm">
<div className="card-header">Neue Verteilung</div>
<div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateBoard}>
<input
className="form-control"
placeholder="z. B. UV-01"
value={boardName}
onChange={(event) => setBoardName(event.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Verteilung erstellen
</button>
</form>
</div>
</div>
</section>
<section className="col-12 col-lg-8">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between">
<span>Alle Verteilungen</span>
<span className="badge text-bg-secondary">{boardCount}</span>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Verteilung</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{boards.map((board) => (
<tr key={board.id}>
<td>{board.name}</td>
<td className="text-end">
<Link
className="btn btn-sm btn-outline-primary"
href={`/projects/${projectId}/circuit-lists?boardId=${board.id}`}
>
Stromkreisliste öffnen
</Link>
</td>
</tr>
))}
{!boards.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</section>
<section className="col-12 col-xl-4">
<div className="card shadow-sm h-100">
<div className="card-header d-flex justify-content-between">
<span>Etagen</span>
<span className="badge text-bg-secondary">{floorCount}</span>
</div>
<div className="card-body border-bottom">
<form className="vstack gap-2" onSubmit={handleCreateFloor}>
<input
className="form-control"
placeholder="z. B. EG"
value={floorName}
onChange={(event) => setFloorName(event.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Etage hinzufügen
</button>
</form>
</div>
<ul className="list-group list-group-flush">
{floors.map((floor) => (
<li className="list-group-item" key={floor.id}>
{floor.name}
</li>
))}
{!floors.length ? (
<li className="list-group-item text-secondary">Noch keine Etagen vorhanden.</li>
) : null}
</ul>
</div>
</section>
<section className="col-12 col-xl-8">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between">
<span>Räume</span>
<span className="badge text-bg-secondary">{roomCount}</span>
</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateRoom}>
<div className="col-12 col-lg-3">
<input
className="form-control"
placeholder="Raumnummer"
value={roomNumber}
onChange={(event) => setRoomNumber(event.target.value)}
/>
</div>
<div className="col-12 col-lg-4">
<input
className="form-control"
placeholder="Raumname"
value={roomName}
onChange={(event) => setRoomName(event.target.value)}
/>
</div>
<div className="col-12 col-lg-3">
<select
className="form-select"
value={roomFloorId}
onChange={(event) => setRoomFloorId(event.target.value)}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
<div className="col-12 col-lg-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Raum anlegen
</button>
</div>
</form>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Raumnummer</th>
<th>Raumname</th>
<th>Etage</th>
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.id}>
<td>{room.roomNumber}</td>
<td>{room.roomName}</td>
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
</tr>
))}
{!rooms.length ? (
<tr>
<td colSpan={3} className="text-center text-secondary py-4">
Noch keine Räume vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</section>
</div>
<section className="card shadow-sm mt-4">
<div className="card-header d-flex justify-content-between">
<span>Projektgeräte / Verbraucher</span>
<span className="badge text-bg-secondary">{projectDeviceCount}</span>
</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateProjectDevice}>
<div className="col-12 col-md-3">
<input
className="form-control"
placeholder="Bezeichnung"
value={projectDeviceForm.name}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, name: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-2">
<input
className="form-control"
placeholder="Kategorie"
value={projectDeviceForm.category}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, category: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
min="0"
value={projectDeviceForm.quantity}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-2">
<input
className="form-control"
type="number"
step="0.01"
min="0"
value={projectDeviceForm.installedPowerPerUnitKw}
onChange={(event) =>
setProjectDeviceForm((current) => ({
...current,
installedPowerPerUnitKw: event.target.value,
}))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
max="1"
value={projectDeviceForm.demandFactor}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<select
className="form-select"
value={projectDeviceForm.phaseCount}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
}
>
<option value="1">1-ph</option>
<option value="3">3-ph</option>
</select>
</div>
<div className="col-6 col-md-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Gerät anlegen
</button>
</div>
</form>
</div>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Bezeichnung</th>
<th>Kategorie</th>
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{projectDevices.map((device) => (
<tr key={device.id}>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.name}
onBlur={(event) =>
event.target.value !== device.name
? handleQuickUpdateProjectDevice(device, "name", event.target.value)
: undefined
}
/>
</td>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.category ?? ""}
onBlur={(event) =>
event.target.value !== (device.category ?? "")
? handleQuickUpdateProjectDevice(device, "category", event.target.value)
: undefined
}
/>
</td>
<td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td>
<td>
<button
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => handleDeleteProjectDevice(device.id)}
>
Löschen
</button>
</td>
</tr>
))}
{!projectDevices.length ? (
<tr>
<td colSpan={6} className="text-center text-secondary py-4">
Noch keine Projektgeräte vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
<div className="mt-4">
<Link className="btn btn-primary" href={`/projects/${projectId}/circuit-lists`}>
3 parallele Stromkreislisten öffnen
</Link>
</div>
</main>
);
}
+378
View File
@@ -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<ProjectDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [projectName, setProjectName] = useState("");
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
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<string | null>(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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 className="h3 mb-1">Projekte</h1>
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
</div>
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="row g-4">
<section className="col-12 col-lg-4">
<div className="card shadow-sm">
<div className="card-header">Neues Projekt</div>
<div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateProject}>
<div>
<label className="form-label">Projektname</label>
<input
className="form-control"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="z. B. Neubau Schule"
/>
</div>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Projekt erstellen
</button>
</form>
</div>
</div>
</section>
<section className="col-12 col-lg-8">
<div className="card shadow-sm">
<div className="card-header">Alle Projekte</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Projekt</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td>{project.name}</td>
<td className="text-end">
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
Projekt öffnen
</Link>
</td>
</tr>
))}
{!projects.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
Noch keine Projekte vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</section>
</div>
<section className="card shadow-sm mt-4">
<div className="card-header">Globale Geräte / Verbraucher</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
<div className="col-12 col-md-3">
<input
className="form-control"
placeholder="Bezeichnung"
value={globalDeviceForm.name}
onChange={(event) => setGlobalDeviceForm((current) => ({ ...current, name: event.target.value }))}
/>
</div>
<div className="col-6 col-md-2">
<input
className="form-control"
placeholder="Kategorie"
value={globalDeviceForm.category}
onChange={(event) =>
setGlobalDeviceForm((current) => ({ ...current, category: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
min="0"
value={globalDeviceForm.quantity}
onChange={(event) =>
setGlobalDeviceForm((current) => ({ ...current, quantity: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-2">
<input
className="form-control"
type="number"
step="0.01"
min="0"
value={globalDeviceForm.installedPowerPerUnitKw}
onChange={(event) =>
setGlobalDeviceForm((current) => ({
...current,
installedPowerPerUnitKw: event.target.value,
}))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
max="1"
value={globalDeviceForm.demandFactor}
onChange={(event) =>
setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<select
className="form-select"
value={globalDeviceForm.phaseCount}
onChange={(event) =>
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
}
>
<option value="1">1-ph</option>
<option value="3">3-ph</option>
</select>
</div>
<div className="col-6 col-md-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Gerät anlegen
</button>
</div>
</form>
</div>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Bezeichnung</th>
<th>Kategorie</th>
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{globalDevices.map((device) => (
<tr key={device.id}>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.name}
onBlur={(event) =>
event.target.value !== device.name
? handleQuickUpdateGlobalDevice(device, "name", event.target.value)
: undefined
}
/>
</td>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.category ?? ""}
onBlur={(event) =>
event.target.value !== (device.category ?? "")
? handleQuickUpdateGlobalDevice(device, "category", event.target.value)
: undefined
}
/>
</td>
<td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td>
<td>
<button
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => handleDeleteGlobalDevice(device.id)}
>
Löschen
</button>
</td>
</tr>
))}
{!globalDevices.length ? (
<tr>
<td colSpan={6} className="text-center text-secondary py-4">
Noch keine globalen Geräte vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</main>
);
}
+12
View File
@@ -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
);
@@ -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;
@@ -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;
@@ -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;
@@ -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
);
+36 -1
View File
@@ -8,6 +8,41 @@
"when": 1777565414148, "when": 1777565414148,
"tag": "0000_bizarre_colossus", "tag": "0000_bizarre_colossus",
"breakpoints": true "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
} }
] ]
} }
@@ -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;
}
}
@@ -18,8 +18,22 @@ export class ConsumerRepository {
id, id,
projectId: input.projectId, projectId: input.projectId,
distributionBoardId: input.distributionBoardId ?? null, distributionBoardId: input.distributionBoardId ?? null,
circuitListId: input.circuitListId ?? null,
roomId: input.roomId ?? null,
circuitNumber: input.circuitNumber ?? null,
description: input.description ?? null,
name: input.name, name: input.name,
category: input.category ?? null, 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, quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw, installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor, demandFactor: input.demandFactor,
@@ -37,8 +51,22 @@ export class ConsumerRepository {
.set({ .set({
projectId: input.projectId, projectId: input.projectId,
distributionBoardId: input.distributionBoardId ?? null, distributionBoardId: input.distributionBoardId ?? null,
circuitListId: input.circuitListId ?? null,
roomId: input.roomId ?? null,
circuitNumber: input.circuitNumber ?? null,
description: input.description ?? null,
name: input.name, name: input.name,
category: input.category ?? null, 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, quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw, installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor, demandFactor: input.demandFactor,
+36
View File
@@ -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);
}
}
@@ -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));
}
}
@@ -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))
);
}
}
+28 -4
View File
@@ -2,20 +2,44 @@ import crypto from "node:crypto";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import type {
CreateProjectInput,
UpdateProjectSettingsInput,
} from "../../shared/validation/consumer.schemas.js";
export class ProjectRepository { export class ProjectRepository {
async list() { async list() {
return db.select().from(projects); return db.select().from(projects);
} }
async create(name: string) { async create(input: CreateProjectInput) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await db.insert(projects).values({ id, name }); const project = {
return { id, name }; 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) { async delete(projectId: string) {
await db.delete(projects).where(eq(projects.id, projectId)); await db.delete(projects).where(eq(projects.id, projectId));
} }
} }
+42
View File
@@ -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;
}
}
+18
View File
@@ -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)]
);
+20 -1
View File
@@ -1,6 +1,8 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
import { distributionBoards } from "./distribution-boards.js"; import { distributionBoards } from "./distribution-boards.js";
import { projects } from "./projects.js"; import { projects } from "./projects.js";
import { rooms } from "./rooms.js";
export const consumers = sqliteTable("consumers", { export const consumers = sqliteTable("consumers", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -10,8 +12,26 @@ export const consumers = sqliteTable("consumers", {
distributionBoardId: text("distribution_board_id").references(() => distributionBoards.id, { distributionBoardId: text("distribution_board_id").references(() => distributionBoards.id, {
onDelete: "set null", 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(), name: text("name").notNull(),
category: text("category"), 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(), quantity: integer("quantity").notNull(),
installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(), installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(),
demandFactor: real("demand_factor").notNull(), demandFactor: real("demand_factor").notNull(),
@@ -20,4 +40,3 @@ export const consumers = sqliteTable("consumers", {
powerFactor: real("power_factor"), powerFactor: real("power_factor"),
note: text("note"), note: text("note"),
}); });
+11
View File
@@ -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),
});
+14
View File
@@ -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"),
});
+18
View File
@@ -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"),
});
+3 -2
View File
@@ -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", { export const projects = sqliteTable("projects", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
}); });
+13
View File
@@ -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(),
});
+18 -1
View File
@@ -2,8 +2,26 @@ export interface Consumer {
id: string; id: string;
projectId: string; projectId: string;
distributionBoardId?: string; distributionBoardId?: string;
circuitListId?: string;
roomId?: string;
roomNumber?: string;
roomName?: string;
floorId?: string;
floorName?: string;
circuitNumber?: string;
description?: string;
name: string; name: string;
category?: 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; quantity: number;
installedPowerPerUnitKw: number; installedPowerPerUnitKw: number;
demandFactor: number; demandFactor: number;
@@ -12,4 +30,3 @@ export interface Consumer {
powerFactor?: number; powerFactor?: number;
note?: string; note?: string;
} }
+21 -4
View File
@@ -8,11 +8,20 @@ import type { Consumer } from "../models/consumer.model.js";
export interface ConsumerWithCalculatedValues extends Consumer { export interface ConsumerWithCalculatedValues extends Consumer {
installedPowerKw: number; installedPowerKw: number;
demandPowerKw: number; demandPowerKw: number;
effectiveVoltageV?: number;
currentA?: number; currentA?: number;
} }
export interface ProjectVoltageDefaults {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
export class PowerBalanceService { export class PowerBalanceService {
enrichConsumer(consumer: Consumer): ConsumerWithCalculatedValues { enrichConsumer(
consumer: Consumer,
projectVoltageDefaults?: ProjectVoltageDefaults
): ConsumerWithCalculatedValues {
const installedPowerKw = calculateInstalledPowerKw({ const installedPowerKw = calculateInstalledPowerKw({
quantity: consumer.quantity, quantity: consumer.quantity,
installedPowerPerUnitKw: consumer.installedPowerPerUnitKw, installedPowerPerUnitKw: consumer.installedPowerPerUnitKw,
@@ -24,11 +33,19 @@ export class PowerBalanceService {
demandFactor: consumer.demandFactor, demandFactor: consumer.demandFactor,
}); });
const effectiveVoltageV =
consumer.voltageV ??
(consumer.phaseCount === 1
? projectVoltageDefaults?.singlePhaseVoltageV
: consumer.phaseCount === 3
? projectVoltageDefaults?.threePhaseVoltageV
: undefined);
let currentA: number | undefined; let currentA: number | undefined;
if (consumer.voltageV && consumer.phaseCount && consumer.powerFactor) { if (effectiveVoltageV && consumer.phaseCount && consumer.powerFactor) {
currentA = calculateCurrentA({ currentA = calculateCurrentA({
demandPowerKw, demandPowerKw,
voltageV: consumer.voltageV, voltageV: effectiveVoltageV,
phaseCount: consumer.phaseCount, phaseCount: consumer.phaseCount,
powerFactor: consumer.powerFactor, powerFactor: consumer.powerFactor,
}); });
@@ -38,8 +55,8 @@ export class PowerBalanceService {
...consumer, ...consumer,
installedPowerKw, installedPowerKw,
demandPowerKw, demandPowerKw,
effectiveVoltageV,
currentA, currentA,
}; };
} }
} }
+118
View File
@@ -1,14 +1,34 @@
export interface ProjectDto { export interface ProjectDto {
id: string; id: string;
name: string; name: string;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
} }
export interface ConsumerWithCalculatedValues { export interface ConsumerWithCalculatedValues {
id: string; id: string;
projectId: string; projectId: string;
distributionBoardId?: string | null; distributionBoardId?: string | null;
circuitListId?: string | null;
roomId?: string | null;
roomNumber?: string;
roomName?: string;
floorId?: string;
floorName?: string;
circuitNumber?: string;
description?: string;
name: string; name: string;
category?: 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; quantity: number;
installedPowerPerUnitKw: number; installedPowerPerUnitKw: number;
demandFactor: number; demandFactor: number;
@@ -18,6 +38,7 @@ export interface ConsumerWithCalculatedValues {
note?: string; note?: string;
installedPowerKw: number; installedPowerKw: number;
demandPowerKw: number; demandPowerKw: number;
effectiveVoltageV?: number;
currentA?: number; currentA?: number;
} }
@@ -27,11 +48,74 @@ export interface DistributionBoardDto {
name: string; 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 { export interface CreateConsumerInput {
projectId: string; projectId: string;
distributionBoardId?: string; distributionBoardId?: string;
circuitListId?: string;
roomId?: string;
circuitNumber?: string;
description?: string;
name: string; name: string;
category?: 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; quantity: number;
installedPowerPerUnitKw: number; installedPowerPerUnitKw: number;
demandFactor: number; demandFactor: number;
@@ -42,3 +126,37 @@ export interface CreateConsumerInput {
} }
export interface UpdateConsumerInput extends 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;
}
+106 -6
View File
@@ -1,8 +1,17 @@
import type { import type {
CircuitListDto,
CreateFloorInput,
CreateProjectDeviceInput,
CreateRoomInput,
ConsumerWithCalculatedValues, ConsumerWithCalculatedValues,
CreateConsumerInput, CreateConsumerInput,
CreateGlobalDeviceInput,
DistributionBoardDto, DistributionBoardDto,
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDto, ProjectDto,
RoomDto,
UpdateConsumerInput, UpdateConsumerInput,
} from "../types"; } from "../types";
@@ -13,6 +22,7 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
"Content-Type": "application/json", "Content-Type": "application/json",
...init?.headers, ...init?.headers,
}, },
cache: "no-store",
}); });
if (!response.ok) { if (!response.ok) {
@@ -20,6 +30,10 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
throw new Error(details || `Request failed with ${response.status}`); throw new Error(details || `Request failed with ${response.status}`);
} }
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -27,6 +41,10 @@ export function listProjects() {
return request<ProjectDto[]>("/api/projects"); return request<ProjectDto[]>("/api/projects");
} }
export function getProject(projectId: string) {
return request<ProjectDto>(`/api/projects/${projectId}`);
}
export function createProject(name: string) { export function createProject(name: string) {
return request<ProjectDto>("/api/projects", { return request<ProjectDto>("/api/projects", {
method: "POST", method: "POST",
@@ -34,6 +52,16 @@ export function createProject(name: string) {
}); });
} }
export function updateProjectSettings(
projectId: string,
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
) {
return request<ProjectDto>(`/api/projects/${projectId}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function listDistributionBoards(projectId: string) { export function listDistributionBoards(projectId: string) {
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`); return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
} }
@@ -45,6 +73,32 @@ export function createDistributionBoard(projectId: string, name: string) {
}); });
} }
export function listCircuitLists(projectId: string) {
return request<CircuitListDto[]>(`/api/projects/${projectId}/circuit-lists`);
}
export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
}
export function createFloor(projectId: string, input: CreateFloorInput) {
return request<FloorDto>(`/api/projects/${projectId}/floors`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function listRooms(projectId: string) {
return request<RoomDto[]>(`/api/projects/${projectId}/rooms`);
}
export function createRoom(projectId: string, input: CreateRoomInput) {
return request<RoomDto>(`/api/projects/${projectId}/rooms`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function listConsumers(projectId: string) { export function listConsumers(projectId: string) {
return request<ConsumerWithCalculatedValues[]>(`/api/consumers/projects/${projectId}`); return request<ConsumerWithCalculatedValues[]>(`/api/consumers/projects/${projectId}`);
} }
@@ -63,10 +117,56 @@ export function updateConsumer(consumerId: string, input: UpdateConsumerInput) {
}); });
} }
export async function deleteConsumer(consumerId: string) { export function deleteConsumer(consumerId: string) {
const response = await fetch(`/api/consumers/${consumerId}`, { method: "DELETE" }); return request<void>(`/api/consumers/${consumerId}`, { method: "DELETE" });
if (!response.ok) { }
const details = await response.text();
throw new Error(details || `Request failed with ${response.status}`); export function listGlobalDevices() {
} return request<GlobalDeviceDto[]>("/api/global-devices");
}
export function createGlobalDevice(input: CreateGlobalDeviceInput) {
return request<GlobalDeviceDto>("/api/global-devices", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateGlobalDevice(globalDeviceId: string, input: CreateGlobalDeviceInput) {
return request<GlobalDeviceDto>(`/api/global-devices/${globalDeviceId}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function deleteGlobalDevice(globalDeviceId: string) {
return request<void>(`/api/global-devices/${globalDeviceId}`, { method: "DELETE" });
}
export function listProjectDevices(projectId: string) {
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
}
export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProjectDevice(
projectId: string,
projectDeviceId: string,
input: CreateProjectDeviceInput
) {
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function deleteProjectDevice(projectId: string, projectDeviceId: string) {
return request<void>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
method: "DELETE",
});
} }
@@ -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);
}
+257 -42
View File
@@ -1,16 +1,54 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js"; import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.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 { PowerBalanceService } from "../../domain/services/power-balance.service.js";
import { import {
createConsumerSchema, createConsumerSchema,
updateConsumerSchema, updateConsumerSchema,
} from "../../shared/validation/consumer.schemas.js"; } from "../../shared/validation/consumer.schemas.js";
const circuitListRepository = new CircuitListRepository();
const consumerRepository = new ConsumerRepository(); const consumerRepository = new ConsumerRepository();
const distributionBoardRepository = new DistributionBoardRepository(); const distributionBoardRepository = new DistributionBoardRepository();
const floorRepository = new FloorRepository();
const projectRepository = new ProjectRepository();
const roomRepository = new RoomRepository();
const powerBalanceService = new PowerBalanceService(); 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( async function validateDistributionBoardOwnership(
projectId: string, projectId: string,
distributionBoardId: string | undefined distributionBoardId: string | undefined
@@ -21,29 +59,134 @@ async function validateDistributionBoardOwnership(
return distributionBoardRepository.existsInProject(projectId, distributionBoardId); 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<string, { floorId: string | null; roomName: string; roomNumber: string }>,
floorById: Map<string, { name: string }>
): 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) { export async function listConsumersByProject(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
if (typeof projectId !== "string") { if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" }); return res.status(400).json({ error: "Invalid projectId" });
} }
const rows = await consumerRepository.listByProject(projectId);
const enriched = rows.map((row) => const [rows, project, floors, rooms] = await Promise.all([
powerBalanceService.enrichConsumer({ consumerRepository.listByProject(projectId),
id: row.id, projectRepository.findById(projectId),
projectId: row.projectId, floorRepository.listByProject(projectId),
distributionBoardId: row.distributionBoardId ?? undefined, roomRepository.listByProject(projectId),
name: row.name, ]);
category: row.category ?? undefined,
quantity: row.quantity, const roomById = new Map(
installedPowerPerUnitKw: row.installedPowerPerUnitKw, rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }])
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,
})
); );
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) { 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const hasValidDistributionBoard = await validateDistributionBoardOwnership( const [hasValidDistributionBoard, hasValidRoom] = await Promise.all([
parsed.data.projectId, validateDistributionBoardOwnership(parsed.data.projectId, parsed.data.distributionBoardId),
parsed.data.distributionBoardId validateRoomOwnership(parsed.data.projectId, parsed.data.roomId),
); ]);
if (!hasValidDistributionBoard) { if (!hasValidDistributionBoard) {
return res return res
.status(400) .status(400)
.json({ error: "Distribution board does not belong to the provided project." }); .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); 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() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const hasValidDistributionBoard = await validateDistributionBoardOwnership( const [hasValidDistributionBoard, hasValidRoom] = await Promise.all([
parsed.data.projectId, validateDistributionBoardOwnership(parsed.data.projectId, parsed.data.distributionBoardId),
parsed.data.distributionBoardId validateRoomOwnership(parsed.data.projectId, parsed.data.roomId),
); ]);
if (!hasValidDistributionBoard) { if (!hasValidDistributionBoard) {
return res return res
.status(400) .status(400)
.json({ error: "Distribution board does not belong to the provided project." }); .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); const row = await consumerRepository.findById(consumerId);
if (!row) { if (!row) {
return res.status(404).json({ error: "Consumer not found" }); return res.status(404).json({ error: "Consumer not found" });
} }
const enriched = powerBalanceService.enrichConsumer({ const [project, floors, rooms] = await Promise.all([
id: row.id, projectRepository.findById(row.projectId),
projectId: row.projectId, floorRepository.listByProject(row.projectId),
distributionBoardId: row.distributionBoardId ?? undefined, roomRepository.listByProject(row.projectId),
name: row.name, ]);
category: row.category ?? undefined, const roomById = new Map(
quantity: row.quantity, rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }])
installedPowerPerUnitKw: row.installedPowerPerUnitKw, );
demandFactor: row.demandFactor, const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }]));
voltageV: row.voltageV ?? undefined,
phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined, const enriched = powerBalanceService.enrichConsumer(
powerFactor: row.powerFactor ?? undefined, buildConsumerFromRow(row as ConsumerRow, roomById, floorById),
note: row.note ?? undefined, project
}); ? {
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
}
: undefined
);
return res.json(enriched); return res.json(enriched);
} }
@@ -1,7 +1,9 @@
import type { Request, Response } from "express"; 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 { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js"; import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
const circuitListRepository = new CircuitListRepository();
const distributionBoardRepository = new DistributionBoardRepository(); const distributionBoardRepository = new DistributionBoardRepository();
export async function listDistributionBoardsByProject(req: Request, res: Response) { 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); 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); return res.status(201).json(board);
} }
@@ -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);
}
@@ -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();
}
@@ -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();
}
+35 -2
View File
@@ -1,6 +1,9 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { ProjectRepository } from "../../db/repositories/project.repository.js"; 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(); const projectRepository = new ProjectRepository();
@@ -14,7 +17,37 @@ export async function createProject(req: Request, res: Response) {
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() }); 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); 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);
}
+39
View File
@@ -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);
}
+4 -1
View File
@@ -1,5 +1,7 @@
import express from "express"; import express from "express";
import { consumerRouter } from "./routes/consumer.routes.js"; 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 { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.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/projects", projectRouter);
app.use("/api/consumers", consumerRouter); app.use("/api/consumers", consumerRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use(errorMiddleware); app.use(errorMiddleware);
app.listen(port, () => { app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`); console.log(`Server running on http://localhost:${port}`);
}); });
+14
View File
@@ -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);
@@ -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);
+16 -1
View File
@@ -1,13 +1,28 @@
import { Router } from "express"; import { Router } from "express";
import { createProject, listProjects } from "../controllers/project.controller.js"; import {
createProject,
getProject,
listProjects,
updateProjectSettings,
} from "../controllers/project.controller.js";
import { import {
createDistributionBoard, createDistributionBoard,
listDistributionBoardsByProject, listDistributionBoardsByProject,
} from "../controllers/distribution-board.controller.js"; } 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(); export const projectRouter = Router();
projectRouter.get("/", listProjects); projectRouter.get("/", listProjects);
projectRouter.post("/", createProject); projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); 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);
+14 -1
View File
@@ -13,8 +13,22 @@ export interface ConsumerDto {
id: string; id: string;
projectId: string; projectId: string;
distributionBoardId: string | null; distributionBoardId: string | null;
circuitListId: string | null;
roomId: string | null;
circuitNumber: string | null;
description: string | null;
name: string; name: string;
category: string | null; 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; quantity: number;
installedPowerPerUnitKw: number; installedPowerPerUnitKw: number;
demandFactor: number; demandFactor: number;
@@ -23,4 +37,3 @@ export interface ConsumerDto {
powerFactor: number | null; powerFactor: number | null;
note: string | null; note: string | null;
} }
+34
View File
@@ -3,8 +3,22 @@ import { z } from "zod";
export const createConsumerSchema = z.object({ export const createConsumerSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
distributionBoardId: z.string().min(1).optional(), 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), name: z.string().min(1),
category: z.string().optional(), category: z.string().optional(),
deviceType: z.string().optional(),
phaseType: z.string().optional(),
tradeOrCostGroup: z.string().optional(),
group: z.string().optional(),
protectionType: z.string().optional(),
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), quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0), installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1), demandFactor: z.number().min(0).max(1),
@@ -18,13 +32,33 @@ export const updateConsumerSchema = createConsumerSchema;
export const createProjectSchema = z.object({ export const createProjectSchema = z.object({
name: z.string().min(1), 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({ export const createDistributionBoardSchema = z.object({
name: z.string().min(1), 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<typeof createConsumerSchema>; export type CreateConsumerInput = z.infer<typeof createConsumerSchema>;
export type CreateProjectInput = z.infer<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectSettingsInput = z.infer<typeof updateProjectSettingsSchema>;
export type CreateDistributionBoardInput = z.infer<typeof createDistributionBoardSchema>; export type CreateDistributionBoardInput = z.infer<typeof createDistributionBoardSchema>;
export type UpdateConsumerInput = z.infer<typeof updateConsumerSchema>; export type UpdateConsumerInput = z.infer<typeof updateConsumerSchema>;
export type CreateFloorInput = z.infer<typeof createFloorSchema>;
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
@@ -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<typeof createGlobalDeviceSchema>;
export type UpdateGlobalDeviceInput = z.infer<typeof updateGlobalDeviceSchema>;
@@ -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<typeof createProjectDeviceSchema>;
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;