First frontend
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6f8;
|
||||
--band: #ffffff;
|
||||
--line: #d7dde5;
|
||||
--line-strong: #b9c3d0;
|
||||
--text: #17212f;
|
||||
--muted: #647084;
|
||||
--accent: #0f766e;
|
||||
--accent-dark: #115e59;
|
||||
--warn-bg: #fff4e5;
|
||||
--warn-line: #f3b562;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.toolbarBand,
|
||||
.entryBand,
|
||||
.tableBand {
|
||||
background: var(--band);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 82px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.iconButton,
|
||||
.primaryButton {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 38px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.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, 1.25fr) minmax(320px, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.projectForm,
|
||||
.consumerForm {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.projectForm {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
height: 92px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.toolbarBand,
|
||||
.projectForm,
|
||||
.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,
|
||||
.consumerForm,
|
||||
.summaryStrip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Leistungsbilanz",
|
||||
description: "Leistungsbilanz fuer elektrische Verbraucher",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PowerBalanceWorkspace } from "../frontend/components/power-balance-workspace";
|
||||
|
||||
export default function Home() {
|
||||
return <PowerBalanceWorkspace />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
|
||||
export class DistributionBoardRepository {
|
||||
async listByProject(projectId: string) {
|
||||
return db.select().from(distributionBoards).where(eq(distributionBoards.projectId, projectId));
|
||||
}
|
||||
|
||||
async create(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const board = { id, projectId, name };
|
||||
await db.insert(distributionBoards).values(board);
|
||||
return board;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, FolderPlus, Plus, RefreshCw, Zap } from "lucide-react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
createConsumer,
|
||||
createDistributionBoard,
|
||||
createProject,
|
||||
listConsumers,
|
||||
listDistributionBoards,
|
||||
listProjects,
|
||||
} from "../utils/api";
|
||||
import type {
|
||||
ConsumerWithCalculatedValues,
|
||||
CreateConsumerInput,
|
||||
DistributionBoardDto,
|
||||
ProjectDto,
|
||||
} from "../types";
|
||||
|
||||
const initialConsumerForm = {
|
||||
name: "",
|
||||
category: "",
|
||||
quantity: "1",
|
||||
installedPowerPerUnitKw: "0.1",
|
||||
demandFactor: "1",
|
||||
voltageV: "230",
|
||||
phaseCount: "1",
|
||||
powerFactor: "1",
|
||||
note: "",
|
||||
};
|
||||
|
||||
type ConsumerForm = typeof initialConsumerForm;
|
||||
|
||||
function toOptionalNumber(value: string) {
|
||||
if (value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined, digits = 2) {
|
||||
if (value === undefined || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function PowerBalanceWorkspace() {
|
||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [distributionBoards, setDistributionBoards] = useState<DistributionBoardDto[]>([]);
|
||||
const [selectedBoardId, setSelectedBoardId] = useState("");
|
||||
const [consumers, setConsumers] = useState<ConsumerWithCalculatedValues[]>([]);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [boardName, setBoardName] = useState("");
|
||||
const [consumerForm, setConsumerForm] = useState<ConsumerForm>(initialConsumerForm);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedProject = projects.find((project) => project.id === selectedProjectId);
|
||||
const selectedBoard = distributionBoards.find((board) => board.id === selectedBoardId);
|
||||
const visibleConsumers = selectedBoardId
|
||||
? consumers.filter((consumer) => consumer.distributionBoardId === selectedBoardId)
|
||||
: consumers;
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
visibleConsumers.reduce(
|
||||
(sum, consumer) => ({
|
||||
installedPowerKw: sum.installedPowerKw + consumer.installedPowerKw,
|
||||
demandPowerKw: sum.demandPowerKw + consumer.demandPowerKw,
|
||||
}),
|
||||
{ installedPowerKw: 0, demandPowerKw: 0 }
|
||||
),
|
||||
[visibleConsumers]
|
||||
);
|
||||
|
||||
async function refreshProjects() {
|
||||
setError(null);
|
||||
const loadedProjects = await listProjects();
|
||||
setProjects(loadedProjects);
|
||||
setSelectedProjectId((current) => current || loadedProjects[0]?.id || "");
|
||||
}
|
||||
|
||||
async function refreshConsumers(projectId: string) {
|
||||
if (!projectId) {
|
||||
setConsumers([]);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setConsumers(await listConsumers(projectId));
|
||||
}
|
||||
|
||||
async function refreshDistributionBoards(projectId: string) {
|
||||
if (!projectId) {
|
||||
setDistributionBoards([]);
|
||||
setSelectedBoardId("");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const boards = await listDistributionBoards(projectId);
|
||||
setDistributionBoards(boards);
|
||||
setSelectedBoardId((current) => (boards.some((board) => board.id === current) ? current : boards[0]?.id || ""));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects()
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Projekte konnten nicht geladen werden."))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDistributionBoards(selectedProjectId).catch((err: unknown) =>
|
||||
setError(err instanceof Error ? err.message : "Verteilungen konnten nicht geladen werden.")
|
||||
);
|
||||
refreshConsumers(selectedProjectId).catch((err: unknown) =>
|
||||
setError(err instanceof Error ? err.message : "Verbraucher konnten nicht geladen werden.")
|
||||
);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
async function handleCreateProject(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const name = projectName.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const project = await createProject(name);
|
||||
setProjects((current) => [...current, project]);
|
||||
setSelectedProjectId(project.id);
|
||||
setProjectName("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Projekt konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDistributionBoard(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const name = boardName.trim();
|
||||
if (!selectedProjectId || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const board = await createDistributionBoard(selectedProjectId, name);
|
||||
setDistributionBoards((current) => [...current, board]);
|
||||
setSelectedBoardId(board.id);
|
||||
setBoardName("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verteilung konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateConsumer(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedProjectId || !consumerForm.name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input: CreateConsumerInput = {
|
||||
projectId: selectedProjectId,
|
||||
distributionBoardId: selectedBoardId || undefined,
|
||||
name: consumerForm.name.trim(),
|
||||
category: consumerForm.category.trim() || undefined,
|
||||
quantity: Number(consumerForm.quantity),
|
||||
installedPowerPerUnitKw: Number(consumerForm.installedPowerPerUnitKw),
|
||||
demandFactor: Number(consumerForm.demandFactor),
|
||||
voltageV: toOptionalNumber(consumerForm.voltageV),
|
||||
phaseCount: consumerForm.phaseCount === "3" ? 3 : 1,
|
||||
powerFactor: toOptionalNumber(consumerForm.powerFactor),
|
||||
note: consumerForm.note.trim() || undefined,
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createConsumer(input);
|
||||
setConsumers((current) => [...current, created]);
|
||||
setConsumerForm(initialConsumerForm);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateConsumerForm(field: keyof ConsumerForm, value: string) {
|
||||
setConsumerForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">TGA / ELT Planung</p>
|
||||
<h1>Leistungsbilanz</h1>
|
||||
</div>
|
||||
<button className="iconButton" type="button" onClick={() => refreshConsumers(selectedProjectId)} title="Aktualisieren">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
|
||||
<section className="toolbarBand">
|
||||
<form className="projectForm" onSubmit={handleCreateProject}>
|
||||
<label>
|
||||
Projekt
|
||||
<select value={selectedProjectId} onChange={(event) => setSelectedProjectId(event.target.value)}>
|
||||
<option value="">Kein Projekt</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Neues Projekt
|
||||
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} placeholder="z. B. BV Neubau" />
|
||||
</label>
|
||||
<button className="primaryButton" type="submit" disabled={isSaving}>
|
||||
<FolderPlus size={17} />
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="boardForm" onSubmit={handleCreateDistributionBoard}>
|
||||
<label>
|
||||
Verteilung
|
||||
<select value={selectedBoardId} onChange={(event) => setSelectedBoardId(event.target.value)}>
|
||||
<option value="">Alle Verteilungen</option>
|
||||
{distributionBoards.map((board) => (
|
||||
<option key={board.id} value={board.id}>
|
||||
{board.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Neue Verteilung
|
||||
<input value={boardName} onChange={(event) => setBoardName(event.target.value)} placeholder="z. B. UV-01" />
|
||||
</label>
|
||||
<button className="primaryButton" type="submit" disabled={!selectedProjectId || isSaving}>
|
||||
<Plus size={17} />
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="summaryStrip" aria-label="Summen">
|
||||
<div>
|
||||
<span>Verbraucher</span>
|
||||
<strong>{visibleConsumers.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Installierte Leistung</span>
|
||||
<strong>{formatNumber(totals.installedPowerKw)} kW</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Berechnete Leistung</span>
|
||||
<strong>{formatNumber(totals.demandPowerKw)} kW</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="entryBand">
|
||||
<form className="consumerForm" onSubmit={handleCreateConsumer}>
|
||||
<label>
|
||||
Verbraucher
|
||||
<input value={consumerForm.name} onChange={(event) => updateConsumerForm("name", event.target.value)} placeholder="z. B. Steckdosen Büro" />
|
||||
</label>
|
||||
<label>
|
||||
Kategorie
|
||||
<input value={consumerForm.category} onChange={(event) => updateConsumerForm("category", event.target.value)} placeholder="Allgemein" />
|
||||
</label>
|
||||
<label>
|
||||
Anzahl
|
||||
<input min="0" type="number" value={consumerForm.quantity} onChange={(event) => updateConsumerForm("quantity", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Leistung je Stück [kW]
|
||||
<input min="0" step="0.01" type="number" value={consumerForm.installedPowerPerUnitKw} onChange={(event) => updateConsumerForm("installedPowerPerUnitKw", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Gleichzeitigkeitsfaktor
|
||||
<input min="0" max="1" step="0.01" type="number" value={consumerForm.demandFactor} onChange={(event) => updateConsumerForm("demandFactor", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Spannung [V]
|
||||
<input min="1" type="number" value={consumerForm.voltageV} onChange={(event) => updateConsumerForm("voltageV", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Phasen
|
||||
<select value={consumerForm.phaseCount} onChange={(event) => updateConsumerForm("phaseCount", event.target.value)}>
|
||||
<option value="1">1-phasig</option>
|
||||
<option value="3">3-phasig</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
cos phi
|
||||
<input min="0" max="1" step="0.01" type="number" value={consumerForm.powerFactor} onChange={(event) => updateConsumerForm("powerFactor", event.target.value)} />
|
||||
</label>
|
||||
<label className="wideField">
|
||||
Bemerkung
|
||||
<input value={consumerForm.note} onChange={(event) => updateConsumerForm("note", event.target.value)} />
|
||||
</label>
|
||||
<button className="primaryButton" type="submit" disabled={!selectedProjectId || isSaving}>
|
||||
<Plus size={17} />
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="tableBand">
|
||||
<div className="tableHeader">
|
||||
<div>
|
||||
<p className="eyebrow">Aktuelles Projekt</p>
|
||||
<h2>{selectedProject?.name || "Noch kein Projekt ausgewählt"}</h2>
|
||||
<p className="subline">{selectedBoard ? `Verteilung: ${selectedBoard.name}` : "Alle Verteilungen"}</p>
|
||||
</div>
|
||||
<div className="statusPill">
|
||||
<Activity size={16} />
|
||||
{isLoading ? "Lädt" : "Bereit"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tableScroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Verbraucher</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Anzahl</th>
|
||||
<th>Leistung je Stück [kW]</th>
|
||||
<th>Installierte Leistung [kW]</th>
|
||||
<th>GZF</th>
|
||||
<th>Berechnete Leistung [kW]</th>
|
||||
<th>Strom [A]</th>
|
||||
<th>Bemerkung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleConsumers.map((consumer) => (
|
||||
<tr key={consumer.id}>
|
||||
<td className="nameCell">
|
||||
<Zap size={15} />
|
||||
{consumer.name}
|
||||
</td>
|
||||
<td>{consumer.category || "-"}</td>
|
||||
<td>{consumer.quantity}</td>
|
||||
<td>{formatNumber(consumer.installedPowerPerUnitKw)}</td>
|
||||
<td>{formatNumber(consumer.installedPowerKw)}</td>
|
||||
<td>{formatNumber(consumer.demandFactor, 2)}</td>
|
||||
<td>{formatNumber(consumer.demandPowerKw)}</td>
|
||||
<td>{formatNumber(consumer.currentA)}</td>
|
||||
<td>{consumer.note || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!visibleConsumers.length ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="emptyState">
|
||||
Lege eine Verteilung an oder erfasse den ersten Verbraucher.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface ProjectDto {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ConsumerWithCalculatedValues {
|
||||
id: string;
|
||||
projectId: string;
|
||||
distributionBoardId?: string | null;
|
||||
name: string;
|
||||
category?: string;
|
||||
quantity: number;
|
||||
installedPowerPerUnitKw: number;
|
||||
demandFactor: number;
|
||||
voltageV?: number;
|
||||
phaseCount?: 1 | 3;
|
||||
powerFactor?: number;
|
||||
note?: string;
|
||||
installedPowerKw: number;
|
||||
demandPowerKw: number;
|
||||
currentA?: number;
|
||||
}
|
||||
|
||||
export interface DistributionBoardDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateConsumerInput {
|
||||
projectId: string;
|
||||
distributionBoardId?: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
quantity: number;
|
||||
installedPowerPerUnitKw: number;
|
||||
demandFactor: number;
|
||||
voltageV?: number;
|
||||
phaseCount?: 1 | 3;
|
||||
powerFactor?: number;
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
ConsumerWithCalculatedValues,
|
||||
CreateConsumerInput,
|
||||
DistributionBoardDto,
|
||||
ProjectDto,
|
||||
} from "../types";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
throw new Error(details || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function listProjects() {
|
||||
return request<ProjectDto[]>("/api/projects");
|
||||
}
|
||||
|
||||
export function createProject(name: string) {
|
||||
return request<ProjectDto>("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listDistributionBoards(projectId: string) {
|
||||
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
||||
}
|
||||
|
||||
export function createDistributionBoard(projectId: string, name: string) {
|
||||
return request<DistributionBoardDto>(`/api/projects/${projectId}/distribution-boards`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listConsumers(projectId: string) {
|
||||
return request<ConsumerWithCalculatedValues[]>(`/api/consumers/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export function createConsumer(input: CreateConsumerInput) {
|
||||
return request<ConsumerWithCalculatedValues>("/api/consumers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
|
||||
|
||||
const distributionBoardRepository = new DistributionBoardRepository();
|
||||
|
||||
export async function listDistributionBoardsByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid projectId" });
|
||||
}
|
||||
|
||||
const result = await distributionBoardRepository.listByProject(projectId);
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
export async function createDistributionBoard(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid projectId" });
|
||||
}
|
||||
|
||||
const parsed = createDistributionBoardSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const board = await distributionBoardRepository.create(projectId, parsed.data.name);
|
||||
return res.status(201).json(board);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Router } from "express";
|
||||
import { createProject, listProjects } from "../controllers/project.controller.js";
|
||||
import {
|
||||
createDistributionBoard,
|
||||
listDistributionBoardsByProject,
|
||||
} from "../controllers/distribution-board.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
projectRouter.get("/", listProjects);
|
||||
projectRouter.post("/", createProject);
|
||||
|
||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||
|
||||
@@ -18,6 +18,10 @@ export const createProjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export const createDistributionBoardSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export type CreateConsumerInput = z.infer<typeof createConsumerSchema>;
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
|
||||
export type CreateDistributionBoardInput = z.infer<typeof createDistributionBoardSchema>;
|
||||
|
||||
Reference in New Issue
Block a user