- Gzf-Picker-Modal mit AMEV-Tabelle 3 (Planungshilfe für elektrische Leistungsbilanzen) im Projektgerät-Modal und im globalen Geräteformular - Docker-Deploy-Tooling (docker-start.sh, run-migrations.js) für den produktiven Container - Seitenleiste ist jetzt einklappbar (Icon-only, Zustand in localStorage)
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { FormEvent, useEffect, useState } from "react";
|
|
import {
|
|
createGlobalDevice,
|
|
createProject,
|
|
deleteGlobalDevice,
|
|
importProjectTransferAsNew,
|
|
listGlobalDevices,
|
|
updateGlobalDevice,
|
|
} from "../../frontend/utils/api";
|
|
import type {
|
|
CreateGlobalDeviceInput,
|
|
GlobalDeviceDto,
|
|
} from "../../frontend/types";
|
|
import { GzfPickerModal } from "../../frontend/components/gzf-picker-modal";
|
|
|
|
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
|
|
|
|
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
|
name: "",
|
|
displayName: "",
|
|
category: "",
|
|
quantity: 1,
|
|
installedPowerPerUnitKw: 0.1,
|
|
demandFactor: 1,
|
|
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 [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
|
const [projectName, setProjectName] = useState("");
|
|
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
|
name: "",
|
|
displayName: "",
|
|
category: "",
|
|
quantity: "1",
|
|
installedPowerPerUnitKw: "0.1",
|
|
demandFactor: "1",
|
|
phaseCount: "1",
|
|
powerFactor: "1",
|
|
note: "",
|
|
});
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [projectImport, setProjectImport] = useState<unknown>(null);
|
|
const [projectImportFilename, setProjectImportFilename] = useState("");
|
|
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
|
|
const [projectImportFileError, setProjectImportFileError] = useState<
|
|
string | null
|
|
>(null);
|
|
const [importedProject, setImportedProject] = useState<{
|
|
projectId: string;
|
|
name: string;
|
|
} | null>(null);
|
|
const [createdProject, setCreatedProject] = useState<{
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
|
|
|
|
async function loadData() {
|
|
setError(null);
|
|
setGlobalDevices(await listGlobalDevices());
|
|
}
|
|
|
|
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);
|
|
setCreatedProject(null);
|
|
try {
|
|
const created = await createProject(projectName.trim());
|
|
setCreatedProject({ id: created.id, name: created.name });
|
|
setProjectName("");
|
|
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleProjectImportFile(file: File | undefined) {
|
|
setProjectImport(null);
|
|
setProjectImportFilename("");
|
|
setProjectImportFileError(null);
|
|
setImportedProject(null);
|
|
if (!file) {
|
|
return;
|
|
}
|
|
try {
|
|
setProjectImport(JSON.parse(await file.text()) as unknown);
|
|
setProjectImportFilename(file.name);
|
|
} catch {
|
|
setProjectImportFileError(
|
|
"Die ausgewählte Datei enthält kein gültiges JSON."
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleImportProject() {
|
|
if (projectImport === null) {
|
|
return;
|
|
}
|
|
setIsSaving(true);
|
|
setError(null);
|
|
setImportedProject(null);
|
|
try {
|
|
const imported = await importProjectTransferAsNew(projectImport);
|
|
setImportedProject(imported);
|
|
setProjectImport(null);
|
|
setProjectImportFilename("");
|
|
setProjectImportInputKey((current) => current + 1);
|
|
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Projektdatei konnte nicht importiert werden."
|
|
);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!globalDeviceForm.name.trim()) {
|
|
return;
|
|
}
|
|
const payload: CreateGlobalDeviceInput = {
|
|
name: globalDeviceForm.name.trim(),
|
|
displayName: globalDeviceForm.displayName.trim() || globalDeviceForm.name.trim(),
|
|
category: globalDeviceForm.category.trim() || undefined,
|
|
quantity: Number(globalDeviceForm.quantity),
|
|
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
|
|
demandFactor: Number(globalDeviceForm.demandFactor),
|
|
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,
|
|
displayName: emptyGlobalDevice.displayName,
|
|
category: emptyGlobalDevice.category ?? "",
|
|
quantity: String(emptyGlobalDevice.quantity),
|
|
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
|
|
demandFactor: String(emptyGlobalDevice.demandFactor),
|
|
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" | "displayName" | "category" | "phaseCount",
|
|
value: string
|
|
) {
|
|
const payload: CreateGlobalDeviceInput = {
|
|
name: key === "name" ? value : device.name,
|
|
displayName: key === "displayName" ? value : device.displayName,
|
|
category: key === "category" ? value : device.category ?? undefined,
|
|
quantity: device.quantity,
|
|
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
|
|
demandFactor: device.demandFactor,
|
|
phaseCount:
|
|
key === "phaseCount"
|
|
? value === "3"
|
|
? 3
|
|
: 1
|
|
: device.phaseCount ?? 1,
|
|
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="page-header">
|
|
<div>
|
|
<div className="kicker">Leistungsbilanz</div>
|
|
<h1>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="d-flex flex-column gap-4">
|
|
<section className="card shadow-sm">
|
|
<div className="card-header">Neues Projekt</div>
|
|
<div className="card-body" style={{ maxWidth: 480 }}>
|
|
<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>
|
|
{createdProject ? (
|
|
<div className="alert alert-success mt-3 mb-0">
|
|
<div className="small mb-2">
|
|
„{createdProject.name}“ wurde angelegt.
|
|
</div>
|
|
<Link
|
|
className="btn btn-sm btn-success"
|
|
href={`/projects/${createdProject.id}`}
|
|
>
|
|
Projekt öffnen
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="card shadow-sm">
|
|
<div className="card-header">Projekt importieren</div>
|
|
<div className="card-body" style={{ maxWidth: 480 }}>
|
|
<p className="text-secondary small">
|
|
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
|
mit neuen internen IDs angelegt.
|
|
</p>
|
|
<label className="form-label" htmlFor="project-overview-import">
|
|
Projektdatei auswählen
|
|
</label>
|
|
<input
|
|
accept=".json,application/json"
|
|
className="form-control"
|
|
disabled={isSaving}
|
|
id="project-overview-import"
|
|
key={projectImportInputKey}
|
|
onChange={(event) =>
|
|
void handleProjectImportFile(event.target.files?.[0])
|
|
}
|
|
type="file"
|
|
/>
|
|
{projectImportFilename ? (
|
|
<div className="form-text">
|
|
{projectImportFilename} ist bereit.
|
|
</div>
|
|
) : null}
|
|
{projectImportFileError ? (
|
|
<div className="text-danger small mt-1">
|
|
{projectImportFileError}
|
|
</div>
|
|
) : null}
|
|
<button
|
|
className="btn btn-outline-primary mt-3 w-100"
|
|
disabled={isSaving || projectImport === null}
|
|
onClick={() => void handleImportProject()}
|
|
type="button"
|
|
>
|
|
Als neues Projekt importieren
|
|
</button>
|
|
{importedProject ? (
|
|
<div className="alert alert-success mt-3 mb-0">
|
|
<div className="small mb-2">
|
|
„{importedProject.name}“ wurde angelegt.
|
|
</div>
|
|
<Link
|
|
className="btn btn-sm btn-success"
|
|
href={`/projects/${importedProject.projectId}`}
|
|
>
|
|
Importiertes Projekt öffnen
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="card shadow-sm">
|
|
<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="Interner Name"
|
|
value={globalDeviceForm.name}
|
|
onChange={(event) => setGlobalDeviceForm((current) => ({ ...current, name: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-3">
|
|
<input
|
|
className="form-control"
|
|
placeholder="Anzeigename"
|
|
value={globalDeviceForm.displayName}
|
|
onChange={(event) => setGlobalDeviceForm((current) => ({ ...current, displayName: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-md-1">
|
|
<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-1">
|
|
<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-2">
|
|
<div className="input-group">
|
|
<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 }))
|
|
}
|
|
/>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={() => setIsGzfPickerOpen(true)}
|
|
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
|
type="button"
|
|
>
|
|
AMEV
|
|
</button>
|
|
</div>
|
|
</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-phasig</option>
|
|
<option value="3">3-phasig</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>Interner Name</th>
|
|
<th>Anzeigename</th>
|
|
<th>Kategorie</th>
|
|
<th>Anzahl</th>
|
|
<th>Leistung je Stück [kW]</th>
|
|
<th>GZF</th>
|
|
<th>Phasenart</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.displayName}
|
|
onBlur={(event) =>
|
|
event.target.value !== device.displayName
|
|
? handleQuickUpdateGlobalDevice(device, "displayName", 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>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
aria-label={`Phasenart für ${device.displayName}`}
|
|
value={String(device.phaseCount ?? 1)}
|
|
onChange={(event) =>
|
|
void handleQuickUpdateGlobalDevice(
|
|
device,
|
|
"phaseCount",
|
|
event.target.value
|
|
)
|
|
}
|
|
>
|
|
<option value="1">1-phasig</option>
|
|
<option value="3">3-phasig</option>
|
|
</select>
|
|
</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={8} className="text-center text-secondary py-4">
|
|
Noch keine globalen Geräte vorhanden.
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{isGzfPickerOpen ? (
|
|
<GzfPickerModal
|
|
onClose={() => setIsGzfPickerOpen(false)}
|
|
onSelect={(value) => {
|
|
setGlobalDeviceForm((current) => ({ ...current, demandFactor: String(value) }));
|
|
setIsGzfPickerOpen(false);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|