Files
leistungsbilanz-ts/src/frontend/components/power-balance-workspace.tsx
T

667 lines
25 KiB
TypeScript

"use client";
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 = {
name: "",
category: "",
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
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) {
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);
}
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<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 [editingConsumerId, setEditingConsumerId] = useState<string | null>(null);
const [editConsumerForm, setEditConsumerForm] = useState<EditConsumerForm | null>(null);
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 boardNames = new Map(distributionBoards.map((board) => [board.id, board.name]));
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]
);
const totalsByBoard = useMemo(() => {
const bucket = new Map<string, { consumerCount: number; installedPowerKw: number; demandPowerKw: number }>();
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);
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(() => {
setEditingConsumerId(null);
setEditConsumerForm(null);
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);
}
}
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 (
<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 Buero" />
</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 Stueck [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} />
Hinzufuegen
</button>
</form>
</section>
<section className="tableBand">
<div className="tableHeader">
<div>
<p className="eyebrow">Aktuelles Projekt</p>
<h2>{selectedProject?.name || "Noch kein Projekt ausgewaehlt"}</h2>
<p className="subline">{selectedBoard ? `Verteilung: ${selectedBoard.name}` : "Alle Verteilungen"}</p>
</div>
<div className="statusPill">
<Activity size={16} />
{isLoading ? "Laedt" : "Bereit"}
</div>
</div>
<div className="boardTotals">
<h3>Summen nach Verteilung</h3>
<div className="tableScroll">
<table>
<thead>
<tr>
<th>Verteilung</th>
<th>Verbraucher</th>
<th>Installierte Leistung [kW]</th>
<th>Berechnete Leistung [kW]</th>
</tr>
</thead>
<tbody>
{totalsByBoard.map((item) => (
<tr key={item.key}>
<td>{item.boardName}</td>
<td>{item.consumerCount}</td>
<td>{formatNumber(item.installedPowerKw)}</td>
<td>{formatNumber(item.demandPowerKw)}</td>
</tr>
))}
{!totalsByBoard.length ? (
<tr>
<td colSpan={4} className="emptyState">
Noch keine Verbraucher fuer eine Summenbildung vorhanden.
</td>
</tr>
) : null}
<tr className="totalRow">
<td>Projekt gesamt</td>
<td>{projectTotals.consumerCount}</td>
<td>{formatNumber(projectTotals.installedPowerKw)}</td>
<td>{formatNumber(projectTotals.demandPowerKw)}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="tableScroll">
<table>
<thead>
<tr>
<th>Verbraucher</th>
<th>Verteilung</th>
<th>Kategorie</th>
<th>Anzahl</th>
<th>Leistung je Stueck [kW]</th>
<th>Installierte Leistung [kW]</th>
<th>GZF</th>
<th>Berechnete Leistung [kW]</th>
<th>Strom [A]</th>
<th>Bemerkung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{visibleConsumers.map((consumer) => {
const isEditing = editingConsumerId === consumer.id && editConsumerForm;
return (
<tr key={consumer.id}>
<td className="nameCell">
<Zap size={15} />
{isEditing ? (
<input value={editConsumerForm.name} onChange={(event) => updateEditConsumerForm("name", event.target.value)} />
) : (
consumer.name
)}
</td>
<td>
{isEditing ? (
<select
value={editConsumerForm.distributionBoardId}
onChange={(event) => updateEditConsumerForm("distributionBoardId", event.target.value)}
>
<option value="">-</option>
{distributionBoards.map((board) => (
<option key={board.id} value={board.id}>
{board.name}
</option>
))}
</select>
) : consumer.distributionBoardId ? (
boardNames.get(consumer.distributionBoardId) || "-"
) : (
"-"
)}
</td>
<td>
{isEditing ? (
<input value={editConsumerForm.category} onChange={(event) => updateEditConsumerForm("category", event.target.value)} />
) : (
consumer.category || "-"
)}
</td>
<td>
{isEditing ? (
<input min="0" type="number" value={editConsumerForm.quantity} onChange={(event) => updateEditConsumerForm("quantity", event.target.value)} />
) : (
consumer.quantity
)}
</td>
<td>
{isEditing ? (
<input
min="0"
step="0.01"
type="number"
value={editConsumerForm.installedPowerPerUnitKw}
onChange={(event) => updateEditConsumerForm("installedPowerPerUnitKw", event.target.value)}
/>
) : (
formatNumber(consumer.installedPowerPerUnitKw)
)}
</td>
<td>{formatNumber(consumer.installedPowerKw)}</td>
<td>
{isEditing ? (
<input min="0" max="1" step="0.01" type="number" value={editConsumerForm.demandFactor} onChange={(event) => updateEditConsumerForm("demandFactor", event.target.value)} />
) : (
formatNumber(consumer.demandFactor)
)}
</td>
<td>{formatNumber(consumer.demandPowerKw)}</td>
<td>
{isEditing ? (
<div className="rowField">
<input min="1" type="number" value={editConsumerForm.voltageV} onChange={(event) => updateEditConsumerForm("voltageV", event.target.value)} />
<select value={editConsumerForm.phaseCount} onChange={(event) => updateEditConsumerForm("phaseCount", event.target.value)}>
<option value="1">1</option>
<option value="3">3</option>
</select>
<input min="0" max="1" step="0.01" type="number" value={editConsumerForm.powerFactor} onChange={(event) => updateEditConsumerForm("powerFactor", event.target.value)} />
</div>
) : (
formatNumber(consumer.currentA)
)}
</td>
<td>
{isEditing ? (
<input value={editConsumerForm.note} onChange={(event) => updateEditConsumerForm("note", event.target.value)} />
) : (
consumer.note || "-"
)}
</td>
<td>
<div className="rowActions">
{isEditing ? (
<>
<button className="iconButton small" type="button" title="Speichern" onClick={() => handleSaveConsumer(consumer.id)} disabled={isSaving}>
<Save size={14} />
</button>
<button className="iconButton small muted" type="button" title="Abbrechen" onClick={cancelEditConsumer}>
<X size={14} />
</button>
</>
) : (
<button className="iconButton small" type="button" title="Bearbeiten" onClick={() => startEditConsumer(consumer)}>
<Pencil size={14} />
</button>
)}
<button className="iconButton small danger" type="button" title="Loeschen" onClick={() => handleDeleteConsumer(consumer.id)} disabled={isSaving}>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
);
})}
{!visibleConsumers.length ? (
<tr>
<td colSpan={11} className="emptyState">
Lege eine Verteilung an oder erfasse den ersten Verbraucher.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</main>
);
}