Apply Petrol/Volt/Kupfer design system and project sidebar

Bring the Econsult-family design tokens (see design-system-econsult.md)
into leistungsbilanz-ts and add a persistent sidebar for the projects
area, mirroring elt-planung-suite / Einsatz-Orga. The sidebar lists all
projects and, inside a project, jumps to its sections; the tree editor
stays sidebar-free since it needs the full width. The projects overview
page is decluttered to a single stacked column (create/import/global
devices) now that the project list lives in the sidebar.
This commit is contained in:
Grovy311 2026-07-30 17:40:11 +02:00
parent cfd4778305
commit 704f4df2a8
6 changed files with 568 additions and 127 deletions

View file

@ -8,15 +8,15 @@ import {
deleteGlobalDevice,
importProjectTransferAsNew,
listGlobalDevices,
listProjects,
updateGlobalDevice,
} from "../../frontend/utils/api";
import type {
CreateGlobalDeviceInput,
GlobalDeviceDto,
ProjectDto,
} from "../../frontend/types";
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
const emptyGlobalDevice: CreateGlobalDeviceInput = {
name: "",
displayName: "",
@ -38,7 +38,6 @@ function toOptionalNumber(value: string) {
}
export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [projectName, setProjectName] = useState("");
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
@ -64,12 +63,14 @@ export default function ProjectsPage() {
projectId: string;
name: string;
} | null>(null);
const [createdProject, setCreatedProject] = useState<{
id: string;
name: string;
} | null>(null);
async function loadData() {
setError(null);
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
setProjects(loadedProjects);
setGlobalDevices(loadedGlobalDevices);
setGlobalDevices(await listGlobalDevices());
}
useEffect(() => {
@ -85,10 +86,12 @@ export default function ProjectsPage() {
}
setIsSaving(true);
setError(null);
setCreatedProject(null);
try {
const created = await createProject(projectName.trim());
setProjects((current) => [...current, created]);
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 {
@ -123,11 +126,11 @@ export default function ProjectsPage() {
setImportedProject(null);
try {
const imported = await importProjectTransferAsNew(projectImport);
setProjects(await listProjects());
setImportedProject(imported);
setProjectImport(null);
setProjectImportFilename("");
setProjectImportInputKey((current) => current + 1);
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
} catch (err) {
setError(
err instanceof Error
@ -222,130 +225,107 @@ export default function ProjectsPage() {
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<div className="page-header">
<div>
<h1 className="h3 mb-1">Projekte</h1>
<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="row g-4">
<section className="col-12 col-lg-4">
<div className="card shadow-sm mb-4">
<div className="card-header">Neues Projekt</div>
<div className="card-body">
<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>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header">Projekt importieren</div>
<div className="card-body">
<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
<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>
{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>
</form>
{createdProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{createdProject.name} wurde angelegt.
</div>
) : null}
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${createdProject.id}`}
>
Projekt öffnen
</Link>
</div>
) : null}
</div>
</section>
<section className="col-12 col-lg-8">
<div className="card shadow-sm">
<div className="card-header">Alle Projekte</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Projekt</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td>{project.name}</td>
<td className="text-end">
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
Projekt öffnen
</Link>
</td>
</tr>
))}
{!projects.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
Noch keine Projekte vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<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>
</div>
<section className="card shadow-sm mt-4">
<div className="card-header">Globale Geräte / Verbraucher</div>
<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">
@ -524,6 +504,7 @@ export default function ProjectsPage() {
</table>
</div>
</section>
</div>
</main>
);
}