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
+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>
);
}