From 81d47ce16f1a10f3af94407950770572a04a8502 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Apr 2026 22:04:08 +0200 Subject: [PATCH] Added power sum per distributionboard --- src/app/globals.css | 42 +++ src/db/repositories/consumer.repository.ts | 34 +- .../distribution-board.repository.ts | 16 +- .../components/power-balance-workspace.tsx | 327 ++++++++++++++++-- src/frontend/types.ts | 2 + src/frontend/utils/api.ts | 16 + src/server/controllers/consumer.controller.ts | 82 ++++- src/server/routes/consumer.routes.ts | 10 +- src/shared/validation/consumer.schemas.ts | 3 + 9 files changed, 501 insertions(+), 31 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 978148e..35f1d21 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -91,6 +91,21 @@ h2 { 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; @@ -197,6 +212,21 @@ select { 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); @@ -247,6 +277,18 @@ 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); diff --git a/src/db/repositories/consumer.repository.ts b/src/db/repositories/consumer.repository.ts index 644da7e..e953ded 100644 --- a/src/db/repositories/consumer.repository.ts +++ b/src/db/repositories/consumer.repository.ts @@ -2,7 +2,10 @@ import crypto from "node:crypto"; import { eq } from "drizzle-orm"; import { db } from "../client.js"; import { consumers } from "../schema/consumers.js"; -import type { CreateConsumerInput } from "../../shared/validation/consumer.schemas.js"; +import type { + CreateConsumerInput, + UpdateConsumerInput, +} from "../../shared/validation/consumer.schemas.js"; export class ConsumerRepository { async listByProject(projectId: string) { @@ -27,5 +30,32 @@ export class ConsumerRepository { }); return { id, ...input }; } -} + async update(consumerId: string, input: UpdateConsumerInput) { + await db + .update(consumers) + .set({ + projectId: input.projectId, + distributionBoardId: input.distributionBoardId ?? null, + 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(consumers.id, consumerId)); + } + + async findById(consumerId: string) { + const [row] = await db.select().from(consumers).where(eq(consumers.id, consumerId)).limit(1); + return row ?? null; + } + + async delete(consumerId: string) { + await db.delete(consumers).where(eq(consumers.id, consumerId)); + } +} diff --git a/src/db/repositories/distribution-board.repository.ts b/src/db/repositories/distribution-board.repository.ts index 1cb0483..9db2cdb 100644 --- a/src/db/repositories/distribution-board.repository.ts +++ b/src/db/repositories/distribution-board.repository.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "../client.js"; import { distributionBoards } from "../schema/distribution-boards.js"; @@ -14,4 +14,18 @@ export class DistributionBoardRepository { await db.insert(distributionBoards).values(board); return board; } + + async existsInProject(projectId: string, distributionBoardId: string) { + const [row] = await db + .select({ id: distributionBoards.id }) + .from(distributionBoards) + .where( + and( + eq(distributionBoards.projectId, projectId), + eq(distributionBoards.id, distributionBoardId) + ) + ) + .limit(1); + return Boolean(row); + } } diff --git a/src/frontend/components/power-balance-workspace.tsx b/src/frontend/components/power-balance-workspace.tsx index 8df7ae8..d135d0e 100644 --- a/src/frontend/components/power-balance-workspace.tsx +++ b/src/frontend/components/power-balance-workspace.tsx @@ -1,20 +1,23 @@ "use client"; -import { Activity, FolderPlus, Plus, RefreshCw, Zap } from "lucide-react"; +import { Activity, FolderPlus, Pencil, Plus, RefreshCw, Save, Trash2, X, Zap } from "lucide-react"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { createConsumer, createDistributionBoard, createProject, + deleteConsumer, listConsumers, listDistributionBoards, listProjects, + updateConsumer, } from "../utils/api"; import type { ConsumerWithCalculatedValues, CreateConsumerInput, DistributionBoardDto, ProjectDto, + UpdateConsumerInput, } from "../types"; const initialConsumerForm = { @@ -29,6 +32,19 @@ const initialConsumerForm = { note: "", }; +interface EditConsumerForm { + name: string; + category: string; + distributionBoardId: string; + quantity: string; + installedPowerPerUnitKw: string; + demandFactor: string; + voltageV: string; + phaseCount: "1" | "3"; + powerFactor: string; + note: string; +} + type ConsumerForm = typeof initialConsumerForm; function toOptionalNumber(value: string) { @@ -48,6 +64,21 @@ function formatNumber(value: number | undefined, digits = 2) { }).format(value); } +function createEditForm(consumer: ConsumerWithCalculatedValues): EditConsumerForm { + return { + name: consumer.name, + category: consumer.category ?? "", + distributionBoardId: consumer.distributionBoardId ?? "", + quantity: String(consumer.quantity), + installedPowerPerUnitKw: String(consumer.installedPowerPerUnitKw), + demandFactor: String(consumer.demandFactor), + voltageV: consumer.voltageV !== undefined ? String(consumer.voltageV) : "", + phaseCount: consumer.phaseCount === 3 ? "3" : "1", + powerFactor: consumer.powerFactor !== undefined ? String(consumer.powerFactor) : "", + note: consumer.note ?? "", + }; +} + export function PowerBalanceWorkspace() { const [projects, setProjects] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(""); @@ -57,6 +88,8 @@ export function PowerBalanceWorkspace() { const [projectName, setProjectName] = useState(""); const [boardName, setBoardName] = useState(""); const [consumerForm, setConsumerForm] = useState(initialConsumerForm); + const [editingConsumerId, setEditingConsumerId] = useState(null); + const [editConsumerForm, setEditConsumerForm] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -79,6 +112,49 @@ export function PowerBalanceWorkspace() { ), [visibleConsumers] ); + const totalsByBoard = useMemo(() => { + const bucket = new Map(); + for (const board of distributionBoards) { + bucket.set(board.id, { consumerCount: 0, installedPowerKw: 0, demandPowerKw: 0 }); + } + bucket.set("__unassigned__", { consumerCount: 0, installedPowerKw: 0, demandPowerKw: 0 }); + + for (const consumer of consumers) { + const key = consumer.distributionBoardId ?? "__unassigned__"; + const entry = bucket.get(key); + if (!entry) { + continue; + } + entry.consumerCount += 1; + entry.installedPowerKw += consumer.installedPowerKw; + entry.demandPowerKw += consumer.demandPowerKw; + } + + return [ + ...distributionBoards.map((board) => ({ + key: board.id, + boardName: board.name, + ...bucket.get(board.id)!, + })), + { + key: "__unassigned__", + boardName: "Ohne Verteilung", + ...bucket.get("__unassigned__")!, + }, + ].filter((item) => item.consumerCount > 0); + }, [consumers, distributionBoards]); + const projectTotals = useMemo( + () => + consumers.reduce( + (sum, consumer) => ({ + consumerCount: sum.consumerCount + 1, + installedPowerKw: sum.installedPowerKw + consumer.installedPowerKw, + demandPowerKw: sum.demandPowerKw + consumer.demandPowerKw, + }), + { consumerCount: 0, installedPowerKw: 0, demandPowerKw: 0 } + ), + [consumers] + ); async function refreshProjects() { setError(null); @@ -116,6 +192,8 @@ export function PowerBalanceWorkspace() { }, []); useEffect(() => { + setEditingConsumerId(null); + setEditConsumerForm(null); refreshDistributionBoards(selectedProjectId).catch((err: unknown) => setError(err instanceof Error ? err.message : "Verteilungen konnten nicht geladen werden.") ); @@ -199,10 +277,74 @@ export function PowerBalanceWorkspace() { } } + async function handleSaveConsumer(consumerId: string) { + if (!selectedProjectId || !editConsumerForm) { + return; + } + + const input: UpdateConsumerInput = { + projectId: selectedProjectId, + distributionBoardId: editConsumerForm.distributionBoardId || undefined, + name: editConsumerForm.name.trim(), + category: editConsumerForm.category.trim() || undefined, + quantity: Number(editConsumerForm.quantity), + installedPowerPerUnitKw: Number(editConsumerForm.installedPowerPerUnitKw), + demandFactor: Number(editConsumerForm.demandFactor), + voltageV: toOptionalNumber(editConsumerForm.voltageV), + phaseCount: editConsumerForm.phaseCount === "3" ? 3 : 1, + powerFactor: toOptionalNumber(editConsumerForm.powerFactor), + note: editConsumerForm.note.trim() || undefined, + }; + + setIsSaving(true); + setError(null); + try { + const updated = await updateConsumer(consumerId, input); + setConsumers((current) => current.map((item) => (item.id === consumerId ? updated : item))); + setEditingConsumerId(null); + setEditConsumerForm(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Verbraucher konnte nicht gespeichert werden."); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteConsumer(consumerId: string) { + setIsSaving(true); + setError(null); + try { + await deleteConsumer(consumerId); + setConsumers((current) => current.filter((item) => item.id !== consumerId)); + if (editingConsumerId === consumerId) { + setEditingConsumerId(null); + setEditConsumerForm(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Verbraucher konnte nicht geloescht werden."); + } finally { + setIsSaving(false); + } + } + + function startEditConsumer(consumer: ConsumerWithCalculatedValues) { + setEditingConsumerId(consumer.id); + setEditConsumerForm(createEditForm(consumer)); + } + + function cancelEditConsumer() { + setEditingConsumerId(null); + setEditConsumerForm(null); + } + function updateConsumerForm(field: keyof ConsumerForm, value: string) { setConsumerForm((current) => ({ ...current, [field]: value })); } + function updateEditConsumerForm(field: keyof EditConsumerForm, value: string) { + setEditConsumerForm((current) => (current ? { ...current, [field]: value } : current)); + } + return (
@@ -282,7 +424,7 @@ export function PowerBalanceWorkspace() {
@@ -330,12 +472,51 @@ export function PowerBalanceWorkspace() {

Aktuelles Projekt

-

{selectedProject?.name || "Noch kein Projekt ausgewählt"}

+

{selectedProject?.name || "Noch kein Projekt ausgewaehlt"}

{selectedBoard ? `Verteilung: ${selectedBoard.name}` : "Alle Verteilungen"}

- {isLoading ? "Lädt" : "Bereit"} + {isLoading ? "Laedt" : "Bereit"} +
+
+ +
+

Summen nach Verteilung

+
+ + + + + + + + + + + {totalsByBoard.map((item) => ( + + + + + + + ))} + {!totalsByBoard.length ? ( + + + + ) : null} + + + + + + + +
VerteilungVerbraucherInstallierte Leistung [kW]Berechnete Leistung [kW]
{item.boardName}{item.consumerCount}{formatNumber(item.installedPowerKw)}{formatNumber(item.demandPowerKw)}
+ Noch keine Verbraucher fuer eine Summenbildung vorhanden. +
Projekt gesamt{projectTotals.consumerCount}{formatNumber(projectTotals.installedPowerKw)}{formatNumber(projectTotals.demandPowerKw)}
@@ -347,35 +528,131 @@ export function PowerBalanceWorkspace() { Verteilung Kategorie Anzahl - Leistung je Stück [kW] + Leistung je Stueck [kW] Installierte Leistung [kW] GZF Berechnete Leistung [kW] Strom [A] Bemerkung + Aktionen - {visibleConsumers.map((consumer) => ( - - - - {consumer.name} - - {consumer.distributionBoardId ? boardNames.get(consumer.distributionBoardId) || "-" : "-"} - {consumer.category || "-"} - {consumer.quantity} - {formatNumber(consumer.installedPowerPerUnitKw)} - {formatNumber(consumer.installedPowerKw)} - {formatNumber(consumer.demandFactor, 2)} - {formatNumber(consumer.demandPowerKw)} - {formatNumber(consumer.currentA)} - {consumer.note || "-"} - - ))} + {visibleConsumers.map((consumer) => { + const isEditing = editingConsumerId === consumer.id && editConsumerForm; + return ( + + + + {isEditing ? ( + updateEditConsumerForm("name", event.target.value)} /> + ) : ( + consumer.name + )} + + + {isEditing ? ( + + ) : consumer.distributionBoardId ? ( + boardNames.get(consumer.distributionBoardId) || "-" + ) : ( + "-" + )} + + + {isEditing ? ( + updateEditConsumerForm("category", event.target.value)} /> + ) : ( + consumer.category || "-" + )} + + + {isEditing ? ( + updateEditConsumerForm("quantity", event.target.value)} /> + ) : ( + consumer.quantity + )} + + + {isEditing ? ( + updateEditConsumerForm("installedPowerPerUnitKw", event.target.value)} + /> + ) : ( + formatNumber(consumer.installedPowerPerUnitKw) + )} + + {formatNumber(consumer.installedPowerKw)} + + {isEditing ? ( + updateEditConsumerForm("demandFactor", event.target.value)} /> + ) : ( + formatNumber(consumer.demandFactor) + )} + + {formatNumber(consumer.demandPowerKw)} + + {isEditing ? ( +
+ updateEditConsumerForm("voltageV", event.target.value)} /> + + updateEditConsumerForm("powerFactor", event.target.value)} /> +
+ ) : ( + formatNumber(consumer.currentA) + )} + + + {isEditing ? ( + updateEditConsumerForm("note", event.target.value)} /> + ) : ( + consumer.note || "-" + )} + + +
+ {isEditing ? ( + <> + + + + ) : ( + + )} + +
+ + + ); + })} {!visibleConsumers.length ? ( - + Lege eine Verteilung an oder erfasse den ersten Verbraucher. diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 02350eb..0c73b1c 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -40,3 +40,5 @@ export interface CreateConsumerInput { powerFactor?: number; note?: string; } + +export interface UpdateConsumerInput extends CreateConsumerInput {} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 60597d1..675ea8d 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -3,6 +3,7 @@ import type { CreateConsumerInput, DistributionBoardDto, ProjectDto, + UpdateConsumerInput, } from "../types"; async function request(url: string, init?: RequestInit): Promise { @@ -54,3 +55,18 @@ export function createConsumer(input: CreateConsumerInput) { body: JSON.stringify(input), }); } + +export function updateConsumer(consumerId: string, input: UpdateConsumerInput) { + return request(`/api/consumers/${consumerId}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export async function deleteConsumer(consumerId: string) { + const response = await fetch(`/api/consumers/${consumerId}`, { method: "DELETE" }); + if (!response.ok) { + const details = await response.text(); + throw new Error(details || `Request failed with ${response.status}`); + } +} diff --git a/src/server/controllers/consumer.controller.ts b/src/server/controllers/consumer.controller.ts index 345d94d..f7a8156 100644 --- a/src/server/controllers/consumer.controller.ts +++ b/src/server/controllers/consumer.controller.ts @@ -1,11 +1,26 @@ import type { Request, Response } from "express"; import { ConsumerRepository } from "../../db/repositories/consumer.repository.js"; +import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { PowerBalanceService } from "../../domain/services/power-balance.service.js"; -import { createConsumerSchema } from "../../shared/validation/consumer.schemas.js"; +import { + createConsumerSchema, + updateConsumerSchema, +} from "../../shared/validation/consumer.schemas.js"; const consumerRepository = new ConsumerRepository(); +const distributionBoardRepository = new DistributionBoardRepository(); const powerBalanceService = new PowerBalanceService(); +async function validateDistributionBoardOwnership( + projectId: string, + distributionBoardId: string | undefined +) { + if (!distributionBoardId) { + return true; + } + return distributionBoardRepository.existsInProject(projectId, distributionBoardId); +} + export async function listConsumersByProject(req: Request, res: Response) { const { projectId } = req.params; if (typeof projectId !== "string") { @@ -36,7 +51,72 @@ export async function createConsumer(req: Request, res: Response) { if (!parsed.success) { return res.status(400).json({ error: parsed.error.flatten() }); } + + const hasValidDistributionBoard = await validateDistributionBoardOwnership( + parsed.data.projectId, + parsed.data.distributionBoardId + ); + if (!hasValidDistributionBoard) { + return res + .status(400) + .json({ error: "Distribution board does not belong to the provided project." }); + } + const created = await consumerRepository.create(parsed.data); const enriched = powerBalanceService.enrichConsumer(created); return res.status(201).json(enriched); } + +export async function updateConsumer(req: Request, res: Response) { + const { consumerId } = req.params; + if (typeof consumerId !== "string") { + return res.status(400).json({ error: "Invalid consumerId" }); + } + + const parsed = updateConsumerSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const hasValidDistributionBoard = await validateDistributionBoardOwnership( + parsed.data.projectId, + parsed.data.distributionBoardId + ); + if (!hasValidDistributionBoard) { + return res + .status(400) + .json({ error: "Distribution board does not belong to the provided project." }); + } + + await consumerRepository.update(consumerId, parsed.data); + const row = await consumerRepository.findById(consumerId); + if (!row) { + return res.status(404).json({ error: "Consumer not found" }); + } + + const enriched = powerBalanceService.enrichConsumer({ + id: row.id, + projectId: row.projectId, + distributionBoardId: row.distributionBoardId ?? undefined, + name: row.name, + category: row.category ?? undefined, + quantity: row.quantity, + installedPowerPerUnitKw: row.installedPowerPerUnitKw, + demandFactor: row.demandFactor, + voltageV: row.voltageV ?? undefined, + phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined, + powerFactor: row.powerFactor ?? undefined, + note: row.note ?? undefined, + }); + return res.json(enriched); +} + +export async function deleteConsumer(req: Request, res: Response) { + const { consumerId } = req.params; + if (typeof consumerId !== "string") { + return res.status(400).json({ error: "Invalid consumerId" }); + } + + await consumerRepository.delete(consumerId); + return res.status(204).send(); +} diff --git a/src/server/routes/consumer.routes.ts b/src/server/routes/consumer.routes.ts index 879d908..f1a5e0a 100644 --- a/src/server/routes/consumer.routes.ts +++ b/src/server/routes/consumer.routes.ts @@ -1,8 +1,14 @@ import { Router } from "express"; -import { createConsumer, listConsumersByProject } from "../controllers/consumer.controller.js"; +import { + createConsumer, + deleteConsumer, + listConsumersByProject, + updateConsumer, +} from "../controllers/consumer.controller.js"; export const consumerRouter = Router(); consumerRouter.get("/projects/:projectId", listConsumersByProject); consumerRouter.post("/", createConsumer); - +consumerRouter.put("/:consumerId", updateConsumer); +consumerRouter.delete("/:consumerId", deleteConsumer); diff --git a/src/shared/validation/consumer.schemas.ts b/src/shared/validation/consumer.schemas.ts index 4717395..7851c60 100644 --- a/src/shared/validation/consumer.schemas.ts +++ b/src/shared/validation/consumer.schemas.ts @@ -14,6 +14,8 @@ export const createConsumerSchema = z.object({ note: z.string().optional(), }); +export const updateConsumerSchema = createConsumerSchema; + export const createProjectSchema = z.object({ name: z.string().min(1), }); @@ -25,3 +27,4 @@ export const createDistributionBoardSchema = z.object({ export type CreateConsumerInput = z.infer; export type CreateProjectInput = z.infer; export type CreateDistributionBoardInput = z.infer; +export type UpdateConsumerInput = z.infer;