Merge branch 'main' into feature/multiplesections
This commit is contained in:
+296
-2
@@ -1,9 +1,303 @@
|
|||||||
|
/* Design-System "Petrol · Volt · Kupfer" v2.1 (H:\design-system-econsult.md),
|
||||||
|
projektübergreifendes Update für die Econsult-Familie (siehe elt-planung-suite).
|
||||||
|
Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die
|
||||||
|
vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen
|
||||||
|
um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt
|
||||||
|
per Ladereihenfolge) statt Bootstrap zu ersetzen. */
|
||||||
|
:root {
|
||||||
|
--color-primary: #1c3f52; /* Petrol */
|
||||||
|
--color-primary-dark: #0f2733; /* Petrol Dark */
|
||||||
|
--color-primary-deep: #0a1920; /* Petrol Deep */
|
||||||
|
--color-accent: #4e7e93; /* Petrol Light */
|
||||||
|
--color-accent-pale: #9dbac9; /* Petrol Pale */
|
||||||
|
--color-signal: #3fa66b; /* Volt - generelle aktiv/positiv-Farbe */
|
||||||
|
--color-signal-dark: #237a47;
|
||||||
|
--color-attention: #c97a3b; /* Kupfer - bewusst selten, "braucht Aufmerksamkeit" */
|
||||||
|
--color-attention-dark: #9c5a26;
|
||||||
|
--color-ink: #182229; /* Graphite */
|
||||||
|
--color-ink-soft: #59666d; /* Slate */
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-surface: #f1f2ee; /* Fog */
|
||||||
|
--color-border: #dee2dc;
|
||||||
|
--color-danger: #b04035;
|
||||||
|
--color-danger-bg: #f7e6e3;
|
||||||
|
--color-warn: #a9791e;
|
||||||
|
|
||||||
|
--font-head: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
|
--font-body: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
|
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #f5f7fb;
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bootstrap-Einfärbung auf Petrol/Volt/Kupfer ────────────────────────── */
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: var(--color-primary);
|
||||||
|
--bs-btn-border-color: var(--color-primary);
|
||||||
|
--bs-btn-hover-bg: var(--color-primary-dark);
|
||||||
|
--bs-btn-hover-border-color: var(--color-primary-dark);
|
||||||
|
--bs-btn-active-bg: var(--color-primary-deep);
|
||||||
|
--bs-btn-active-border-color: var(--color-primary-deep);
|
||||||
|
--bs-btn-disabled-bg: var(--color-primary);
|
||||||
|
--bs-btn-disabled-border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
--bs-btn-color: var(--color-primary);
|
||||||
|
--bs-btn-border-color: var(--color-primary);
|
||||||
|
--bs-btn-hover-bg: var(--color-primary);
|
||||||
|
--bs-btn-hover-border-color: var(--color-primary);
|
||||||
|
--bs-btn-active-bg: var(--color-primary-dark);
|
||||||
|
--bs-btn-active-border-color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success,
|
||||||
|
.btn-outline-success {
|
||||||
|
--bs-btn-bg: var(--color-signal);
|
||||||
|
--bs-btn-border-color: var(--color-signal);
|
||||||
|
--bs-btn-color: #fff;
|
||||||
|
--bs-btn-hover-bg: var(--color-signal-dark);
|
||||||
|
--bs-btn-hover-border-color: var(--color-signal-dark);
|
||||||
|
--bs-btn-active-bg: var(--color-signal-dark);
|
||||||
|
--bs-btn-active-border-color: var(--color-signal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-success {
|
||||||
|
--bs-btn-bg: transparent;
|
||||||
|
--bs-btn-color: var(--color-signal-dark);
|
||||||
|
--bs-btn-hover-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(28, 63, 82, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-bg-secondary {
|
||||||
|
background-color: var(--color-surface) !important;
|
||||||
|
color: var(--color-ink-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
--bs-alert-color: var(--color-signal-dark);
|
||||||
|
--bs-alert-bg: #eaf6f0;
|
||||||
|
--bs-alert-border-color: #b6e2ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
var(--color-primary-deep) 0%,
|
||||||
|
var(--color-primary-dark) 45%,
|
||||||
|
var(--color-primary) 100%
|
||||||
|
);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 22px 22px 18px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wordmark {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--color-signal), var(--color-attention));
|
||||||
|
margin-top: 9px;
|
||||||
|
width: 42px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tag {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent-pale);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-label {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(199, 214, 224, 0.7);
|
||||||
|
padding: 16px 14px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #c7d6e0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link.active {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: inset 3px 0 0 var(--color-signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon {
|
||||||
|
width: 1.1em;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-content {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.kpi:hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--radius);
|
||||||
|
border-top: 3px solid var(--color-primary);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(15, 39, 51, 0.05),
|
||||||
|
0 10px 24px -14px rgba(15, 39, 51, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table td input.form-control-sm,
|
.table td input.form-control-sm,
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { AppShell } from "../frontend/components/AppShell";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Leistungsbilanz",
|
title: "Leistungsbilanz",
|
||||||
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -661,9 +661,10 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container py-4">
|
<main className="container py-4">
|
||||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
|
<div className="kicker">Projekt</div>
|
||||||
|
<h1>{project?.name ?? "Projekt"}</h1>
|
||||||
<p className="text-secondary mb-0">
|
<p className="text-secondary mb-0">
|
||||||
{project?.internalProjectNumber
|
{project?.internalProjectNumber
|
||||||
? `Projektnummer ${project.internalProjectNumber} · `
|
? `Projektnummer ${project.internalProjectNumber} · `
|
||||||
@@ -688,9 +689,28 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<a className="kpi" href="#verteilungen">
|
||||||
|
<div className="kpi-label">Verteilungen</div>
|
||||||
|
<div className="kpi-value">{boardCount}</div>
|
||||||
|
</a>
|
||||||
|
<a className="kpi" href="#etagen">
|
||||||
|
<div className="kpi-label">Etagen</div>
|
||||||
|
<div className="kpi-value">{floorCount}</div>
|
||||||
|
</a>
|
||||||
|
<a className="kpi" href="#raeume">
|
||||||
|
<div className="kpi-label">Räume</div>
|
||||||
|
<div className="kpi-value">{roomCount}</div>
|
||||||
|
</a>
|
||||||
|
<a className="kpi" href="#projektgeraete">
|
||||||
|
<div className="kpi-label">Projektgeräte</div>
|
||||||
|
<div className="kpi-value">{projectDeviceCount}</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
{project ? (
|
{project ? (
|
||||||
<section className="col-12">
|
<section className="col-12" id="verlauf">
|
||||||
<ProjectVersionHistory
|
<ProjectVersionHistory
|
||||||
currentRevision={project.currentRevision}
|
currentRevision={project.currentRevision}
|
||||||
onProjectStateChange={() => window.location.reload()}
|
onProjectStateChange={() => window.location.reload()}
|
||||||
@@ -699,7 +719,7 @@ export default function ProjectDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="col-12">
|
<section className="col-12" id="verteilungen">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -792,7 +812,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-5">
|
<section className="col-12 col-lg-5" id="etagen">
|
||||||
<div className="card shadow-sm h-100">
|
<div className="card shadow-sm h-100">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -820,7 +840,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-7">
|
<section className="col-12 col-lg-7" id="raeume">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -866,7 +886,7 @@ export default function ProjectDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card shadow-sm mt-4">
|
<section className="card shadow-sm mt-4" id="projektgeraete">
|
||||||
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span>Projektgeräte</span>
|
<span>Projektgeräte</span>
|
||||||
|
|||||||
+98
-117
@@ -8,15 +8,15 @@ import {
|
|||||||
deleteGlobalDevice,
|
deleteGlobalDevice,
|
||||||
importProjectTransferAsNew,
|
importProjectTransferAsNew,
|
||||||
listGlobalDevices,
|
listGlobalDevices,
|
||||||
listProjects,
|
|
||||||
updateGlobalDevice,
|
updateGlobalDevice,
|
||||||
} from "../../frontend/utils/api";
|
} from "../../frontend/utils/api";
|
||||||
import type {
|
import type {
|
||||||
CreateGlobalDeviceInput,
|
CreateGlobalDeviceInput,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDto,
|
|
||||||
} from "../../frontend/types";
|
} from "../../frontend/types";
|
||||||
|
|
||||||
|
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
|
||||||
|
|
||||||
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@@ -38,7 +38,6 @@ function toOptionalNumber(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
|
||||||
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
||||||
const [projectName, setProjectName] = useState("");
|
const [projectName, setProjectName] = useState("");
|
||||||
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
||||||
@@ -64,12 +63,14 @@ export default function ProjectsPage() {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [createdProject, setCreatedProject] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setError(null);
|
setError(null);
|
||||||
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
|
setGlobalDevices(await listGlobalDevices());
|
||||||
setProjects(loadedProjects);
|
|
||||||
setGlobalDevices(loadedGlobalDevices);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,10 +86,12 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setCreatedProject(null);
|
||||||
try {
|
try {
|
||||||
const created = await createProject(projectName.trim());
|
const created = await createProject(projectName.trim());
|
||||||
setProjects((current) => [...current, created]);
|
setCreatedProject({ id: created.id, name: created.name });
|
||||||
setProjectName("");
|
setProjectName("");
|
||||||
|
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -123,11 +126,11 @@ export default function ProjectsPage() {
|
|||||||
setImportedProject(null);
|
setImportedProject(null);
|
||||||
try {
|
try {
|
||||||
const imported = await importProjectTransferAsNew(projectImport);
|
const imported = await importProjectTransferAsNew(projectImport);
|
||||||
setProjects(await listProjects());
|
|
||||||
setImportedProject(imported);
|
setImportedProject(imported);
|
||||||
setProjectImport(null);
|
setProjectImport(null);
|
||||||
setProjectImportFilename("");
|
setProjectImportFilename("");
|
||||||
setProjectImportInputKey((current) => current + 1);
|
setProjectImportInputKey((current) => current + 1);
|
||||||
|
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
@@ -222,130 +225,107 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container py-4">
|
<main className="container py-4">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
<div className="page-header">
|
||||||
<div>
|
<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>
|
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||||
|
|
||||||
<div className="row g-4">
|
<div className="d-flex flex-column gap-4">
|
||||||
<section className="col-12 col-lg-4">
|
<section className="card shadow-sm">
|
||||||
<div className="card shadow-sm mb-4">
|
<div className="card-header">Neues Projekt</div>
|
||||||
<div className="card-header">Neues Projekt</div>
|
<div className="card-body" style={{ maxWidth: 480 }}>
|
||||||
<div className="card-body">
|
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
<div>
|
||||||
<div>
|
<label className="form-label">Projektname</label>
|
||||||
<label className="form-label">Projektname</label>
|
<input
|
||||||
<input
|
className="form-control"
|
||||||
className="form-control"
|
value={projectName}
|
||||||
value={projectName}
|
onChange={(event) => setProjectName(event.target.value)}
|
||||||
onChange={(event) => setProjectName(event.target.value)}
|
placeholder="z. B. Neubau Schule"
|
||||||
placeholder="z. B. Neubau Schule"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<button className="btn btn-primary" type="submit" disabled={isSaving}>
|
||||||
<button className="btn btn-primary" type="submit" disabled={isSaving}>
|
Projekt erstellen
|
||||||
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
|
|
||||||
</button>
|
</button>
|
||||||
{importedProject ? (
|
</form>
|
||||||
<div className="alert alert-success mt-3 mb-0">
|
{createdProject ? (
|
||||||
<div className="small mb-2">
|
<div className="alert alert-success mt-3 mb-0">
|
||||||
„{importedProject.name}“ wurde angelegt.
|
<div className="small mb-2">
|
||||||
</div>
|
„{createdProject.name}“ wurde angelegt.
|
||||||
<Link
|
|
||||||
className="btn btn-sm btn-success"
|
|
||||||
href={`/projects/${importedProject.projectId}`}
|
|
||||||
>
|
|
||||||
Importiertes Projekt öffnen
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<Link
|
||||||
</div>
|
className="btn btn-sm btn-success"
|
||||||
|
href={`/projects/${createdProject.id}`}
|
||||||
|
>
|
||||||
|
Projekt öffnen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-8">
|
<section className="card shadow-sm">
|
||||||
<div className="card shadow-sm">
|
<div className="card-header">Projekt importieren</div>
|
||||||
<div className="card-header">Alle Projekte</div>
|
<div className="card-body" style={{ maxWidth: 480 }}>
|
||||||
<div className="table-responsive">
|
<p className="text-secondary small">
|
||||||
<table className="table table-hover align-middle mb-0">
|
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||||
<thead>
|
mit neuen internen IDs angelegt.
|
||||||
<tr>
|
</p>
|
||||||
<th>Projekt</th>
|
<label className="form-label" htmlFor="project-overview-import">
|
||||||
<th className="text-end">Aktionen</th>
|
Projektdatei auswählen
|
||||||
</tr>
|
</label>
|
||||||
</thead>
|
<input
|
||||||
<tbody>
|
accept=".json,application/json"
|
||||||
{projects.map((project) => (
|
className="form-control"
|
||||||
<tr key={project.id}>
|
disabled={isSaving}
|
||||||
<td>{project.name}</td>
|
id="project-overview-import"
|
||||||
<td className="text-end">
|
key={projectImportInputKey}
|
||||||
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
|
onChange={(event) =>
|
||||||
Projekt öffnen
|
void handleProjectImportFile(event.target.files?.[0])
|
||||||
</Link>
|
}
|
||||||
</td>
|
type="file"
|
||||||
</tr>
|
/>
|
||||||
))}
|
{projectImportFilename ? (
|
||||||
{!projects.length ? (
|
<div className="form-text">
|
||||||
<tr>
|
{projectImportFilename} ist bereit.
|
||||||
<td colSpan={2} className="text-center text-secondary py-4">
|
</div>
|
||||||
Noch keine Projekte vorhanden.
|
) : null}
|
||||||
</td>
|
{projectImportFileError ? (
|
||||||
</tr>
|
<div className="text-danger small mt-1">
|
||||||
) : null}
|
{projectImportFileError}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
) : null}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="card shadow-sm mt-4">
|
<section className="card shadow-sm">
|
||||||
<div className="card-header">Globale Geräte / Verbraucher</div>
|
<div className="card-header">Globale Geräte / Verbraucher</div>
|
||||||
<div className="card-body border-bottom">
|
<div className="card-body border-bottom">
|
||||||
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
||||||
<div className="col-12 col-md-3">
|
<div className="col-12 col-md-3">
|
||||||
@@ -524,6 +504,7 @@ export default function ProjectsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
|
||||||
|
|
||||||
|
if (!showSidebar) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="app-shell-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listProjects } from "../utils/api";
|
||||||
|
import type { ProjectDto } from "../types";
|
||||||
|
|
||||||
|
const PROJECT_SECTIONS = [
|
||||||
|
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
||||||
|
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
||||||
|
{ id: "etagen", label: "Etagen", icon: "▥" },
|
||||||
|
{ id: "raeume", label: "Räume", icon: "⌂" },
|
||||||
|
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
|
||||||
|
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||||
|
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function refreshProjects() {
|
||||||
|
listProjects()
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(() => setProjects([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProjects();
|
||||||
|
window.addEventListener("leistungsbilanz:projects-changed", refreshProjects);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("leistungsbilanz:projects-changed", refreshProjects);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) {
|
||||||
|
setActiveSection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = PROJECT_SECTIONS.map((section) =>
|
||||||
|
document.getElementById(section.id)
|
||||||
|
).filter((element): element is HTMLElement => element !== null);
|
||||||
|
|
||||||
|
if (!elements.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||||
|
if (visible[0]) {
|
||||||
|
setActiveSection(visible[0].target.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.forEach((element) => observer.observe(element));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [activeProjectId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<div className="sidebar-wordmark">Leistungsbilanz</div>
|
||||||
|
<div className="sidebar-bar" />
|
||||||
|
<div className="sidebar-tag">Stromkreis- & Lastberechnung</div>
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<Link
|
||||||
|
href="/projects"
|
||||||
|
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
|
||||||
|
>
|
||||||
|
<span className="sidebar-icon">▦</span>
|
||||||
|
Alle Projekte
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="sidebar-section-label">Projekte</div>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className={
|
||||||
|
project.id === activeProjectId ? "sidebar-link active" : "sidebar-link"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="sidebar-icon">▣</span>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!projects.length ? (
|
||||||
|
<div className="sidebar-section-label" style={{ opacity: 0.6 }}>
|
||||||
|
Noch keine Projekte
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeProjectId && (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-section-label">In diesem Projekt</div>
|
||||||
|
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||||
|
>
|
||||||
|
<span className="sidebar-icon">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user