Web: Dashboards in der Seitenleiste, Karten mehrfach, Buchen ohne Seitenwechsel

Drei Dinge in einem, weil sie dieselbe Umstellung brauchen.

Karten sind jetzt Instanzen: Die Anordnung merkt sich neben der Kennung auch
die Art und ein "props" je Karte. Dadurch laesst sich dieselbe Art mehrfach
auflegen, und "Verlauf eines Artikels" merkt sich endlich, welcher Artikel
gemeint ist - vorher lag diese Wahl nur in useState und war nach jedem
Neuladen wieder weg, was die Karte zum Beobachten unbrauchbar machte. Wer
mehrere Artikel im Blick haben will, legt jetzt einfach mehrere Karten an.

Mehrere Dashboards stehen als eingerueckte Eintraege unter "Übersicht" in der
Seitenleiste, anlegen und umbenennen laeuft ueber die Kopfzeile. Ab dem
zweiten werden sie aufgezaehlt; bei einem einzigen waere die Liste nur eine
Verdopplung.

Neu sind die Karten "Einlagern" und "Auslagern": Artikel suchen, Menge
eintragen, gebucht. MHD und Lagerort bleiben bewusst weg - wer die braucht,
ist auf der vollen Seite besser aufgehoben. Die alte Karte "Schnellzugriff"
bleibt als reine Verlinkung bestehen.

Der Bestaetigungsdialog kann jetzt auch nach Text fragen (prompt: true). Das
war noetig, weil Dashboards benannt werden - und window.prompt sieht aus wie
der Browser, nicht wie diese Anwendung; derselbe Grund, aus dem es hier schon
kein window.confirm gibt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 19:18:51 +02:00
parent f62289f975
commit 89598147ed
6 changed files with 486 additions and 79 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import GridLayout, { WidthProvider } from "react-grid-layout";
import { api } from "../api";
import { useAuth } from "../auth";
@@ -16,33 +17,54 @@ const SPALTEN = 12;
// oder unnötig hoch.
const ZEILENHOEHE = 40;
/** Kennung einer einzelnen Karte. Sie muss im Dashboard eindeutig sein, damit
* dieselbe Kartenart mehrfach darauf liegen kann. */
function neueKennung(art, vorhanden) {
let nummer = 1;
let kennung = `${art}-${nummer}`;
while (vorhanden.some((k) => k.i === kennung)) {
nummer += 1;
kennung = `${art}-${nummer}`;
}
return kennung;
}
/**
* Startseite als frei zusammenstellbares Raster.
*
* Ansehen ist der Normalzustand. Erst „Anordnen“ schaltet Ziehen, Größe ändern
* und Entfernen frei so verschiebt man nichts versehentlich beim Lesen.
* Gespeichert wird ausdrücklich, damit ein Probieren folgenlos bleibt.
*
* Eine Karte ist eine *Instanz*: „i“ ist ihre Kennung, „type“ ihre Art. Erst
* dadurch lassen sich zwei Verlaufskarten für zwei Artikel nebeneinander legen.
*/
export default function Dashboard() {
const { isAdmin } = useAuth();
const confirm = useConfirm();
const toast = useToast();
const navigate = useNavigate();
const { id: routeId } = useParams();
const [layout, setLayout] = useState([]);
const [dashboards, setDashboards] = useState([]);
const [entwurf, setEntwurf] = useState(null); // nur im Bearbeitungsmodus gesetzt
const [herkunft, setHerkunft] = useState("builtin");
const [erzwungen, setErzwungen] = useState(false);
const [hatVorgabe, setHatVorgabe] = useState(false);
const [fehler, setFehler] = useState(null);
const [menuOffen, setMenuOffen] = useState(false);
const [produkte, setProdukte] = useState([]);
const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id;
const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0];
const bearbeiten = entwurf !== null;
const aktuell = entwurf ?? layout;
const aktuell = entwurf ?? bereinige(aktives?.layout);
async function laden() {
try {
const antwort = await api.dashboardLayout();
setLayout(bereinige(antwort.layout));
const antwort = await api.dashboards();
setDashboards(antwort.dashboards);
setHerkunft(antwort.source);
setErzwungen(antwort.enforced);
setHatVorgabe(antwort.has_default);
@@ -51,8 +73,22 @@ export default function Dashboard() {
}
}
/** Die Seitenleiste fuehrt dieselbe Liste und muss von Aenderungen erfahren. */
function meldeAenderung() {
window.dispatchEvent(new Event("dashboards-geaendert"));
}
useEffect(() => { laden(); }, []);
// Artikelliste nur einmal für die Einstellung der artikelbezogenen Karten.
useEffect(() => {
api.listProducts().then(setProdukte).catch(() => {});
}, []);
// Beim Wechsel des Dashboards einen offenen Entwurf verwerfen sonst
// landete er beim Speichern auf dem falschen.
useEffect(() => { setEntwurf(null); setMenuOffen(false); }, [aktivesId]);
/**
* Unbekannte Karten aussortieren z.B. nach einem Update, das eine entfernt.
* Zusätzlich zu kleine Karten auf ihre Mindestgröße heben: Gespeicherte
@@ -61,26 +97,22 @@ export default function Dashboard() {
*/
function bereinige(liste) {
return (liste || [])
.filter((k) => KARTEN[k.i])
.map((k) => ({ ...k, type: k.type ?? k.i, props: k.props ?? {} }))
.filter((k) => KARTEN[k.type])
.map((k) => ({
...k,
w: Math.max(k.w, KARTEN[k.i].min.w),
h: Math.max(k.h, KARTEN[k.i].min.h),
w: Math.max(k.w, KARTEN[k.type].min.w),
h: Math.max(k.h, KARTEN[k.type].min.h),
}));
}
const verfuegbar = useMemo(
() => KARTEN_IDS.filter((id) => !aktuell.some((k) => k.i === id)),
[aktuell],
);
/** Mindestgrößen kommen aus dem Verzeichnis und gehören in die Rasterangabe
* nicht zusätzlich als data-grid, sonst gibt es zwei Quellen der Wahrheit. */
const rasterLayout = useMemo(
() => aktuell.map((k) => ({
...k,
minW: KARTEN[k.i]?.min.w ?? 2,
minH: KARTEN[k.i]?.min.h ?? 1,
minW: KARTEN[k.type]?.min.w ?? 2,
minH: KARTEN[k.type]?.min.h ?? 1,
})),
[aktuell],
);
@@ -89,23 +121,33 @@ export default function Dashboard() {
setEntwurf(aktuell.map((k) => ({ ...k })));
}
function hinzufuegen(id) {
const karte = KARTEN[id];
const untenY = aktuell.reduce((max, k) => Math.max(max, k.y + k.h), 0);
setEntwurf([...(entwurf ?? []), { i: id, x: 0, y: untenY, ...karte.standard }]);
function hinzufuegen(art) {
const karte = KARTEN[art];
const liste = entwurf ?? aktuell;
const untenY = liste.reduce((max, k) => Math.max(max, k.y + k.h), 0);
setEntwurf([
...liste,
{ i: neueKennung(art, liste), type: art, x: 0, y: untenY, props: {}, ...karte.standard },
]);
setMenuOffen(false);
}
function entfernen(id) {
setEntwurf((alt) => (alt ?? []).filter((k) => k.i !== id));
function entfernen(kennung) {
setEntwurf((alt) => (alt ?? []).filter((k) => k.i !== kennung));
}
/** Einstellung einer einzelnen Karte ändern (z.B. welcher Artikel). */
function setzeProps(kennung, props) {
setEntwurf((alt) => (alt ?? []).map((k) => (
k.i === kennung ? { ...k, props: { ...k.props, ...props } } : k
)));
}
async function speichern() {
setFehler(null);
try {
const antwort = await api.saveDashboardLayout(schlank(entwurf));
setLayout(bereinige(antwort.layout));
setHerkunft(antwort.source);
const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) });
setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d)));
setEntwurf(null);
toast("Anordnung gespeichert.");
} catch (err) {
@@ -113,10 +155,67 @@ export default function Dashboard() {
}
}
async function anlegen() {
const name = await confirm({
title: "Neues Dashboard",
prompt: true,
defaultValue: "Neues Dashboard",
confirmLabel: "Anlegen",
});
if (!name) return;
try {
const neu = await api.createDashboard({ name, layout: [] });
await laden();
meldeAenderung();
navigate(`/d/${neu.id}`);
toast(`${neu.name}“ angelegt.`);
} catch (err) {
setFehler(err.message);
}
}
async function umbenennen() {
const name = await confirm({
title: "Dashboard umbenennen",
prompt: true,
defaultValue: aktives?.name ?? "",
confirmLabel: "Umbenennen",
});
if (!name) return;
try {
const geaendert = await api.updateDashboard(aktives.id, { name });
setDashboards((alt) => alt.map((d) => (d.id === geaendert.id ? geaendert : d)));
meldeAenderung();
toast("Umbenannt.");
} catch (err) {
setFehler(err.message);
}
}
async function loeschen() {
const ok = await confirm({
title: `${aktives?.name}“ löschen?`,
message: "Das Dashboard und seine Karten werden entfernt.",
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteDashboard(aktives.id);
setEntwurf(null);
await laden();
meldeAenderung();
navigate("/");
toast("Dashboard gelöscht.");
} catch (err) {
setFehler(err.message);
}
}
async function alsVorgabe() {
setFehler(null);
try {
await api.saveDashboardDefault(schlank(entwurf ?? layout));
await api.saveDashboardDefault();
setHatVorgabe(true);
toast("Als Vorgabe für alle Benutzer gespeichert.");
} catch (err) {
@@ -129,7 +228,7 @@ export default function Dashboard() {
try {
const antwort = await api.setDashboardEnforced(wert);
setErzwungen(antwort.enforced);
setLayout(bereinige(antwort.layout));
setDashboards(antwort.dashboards);
setHerkunft(antwort.source);
setEntwurf(null);
toast(wert
@@ -143,15 +242,17 @@ export default function Dashboard() {
async function zuruecksetzen() {
const ok = await confirm({
title: "Auf Vorgabe zurücksetzen?",
message: "Deine persönliche Anordnung wird verworfen.",
message: "Deine persönlichen Dashboards werden verworfen.",
confirmLabel: "Zurücksetzen",
danger: true,
});
if (!ok) return;
try {
await api.resetDashboardLayout();
await api.resetDashboards();
setEntwurf(null);
await laden();
meldeAenderung();
navigate("/");
toast("Auf die Vorgabe zurückgesetzt.");
} catch (err) {
setFehler(err.message);
@@ -159,11 +260,15 @@ export default function Dashboard() {
}
/** Nur die Rasterfelder speichern react-grid-layout hängt viel Beiwerk an.
* Der gewählte Zeitraum gehört zur Karte und wird mitgespeichert. */
* Art, Einstellungen und Zeitraum gehören zur Karte und bleiben erhalten. */
function schlank(liste) {
return (liste || []).map(({ i, x, y, w, h, tage }) => (
tage ? { i, x, y, w, h, tage } : { i, x, y, w, h }
));
const vorhanden = new Map((entwurf ?? aktuell).map((k) => [k.i, k]));
return (liste || []).map(({ i, x, y, w, h }) => {
const alt = vorhanden.get(i) ?? {};
const eintrag = { i, type: alt.type ?? i, x, y, w, h, props: alt.props ?? {} };
if (alt.tage) eintrag.tage = alt.tage;
return eintrag;
});
}
/**
@@ -171,26 +276,30 @@ export default function Dashboard() {
* die Wahl still gespeichert sonst wäre sie nach dem nächsten Laden wieder
* weg, ohne dass es einen sichtbaren Speichern-Knopf gäbe.
*/
async function setzeZeitraum(id, tage) {
const geaendert = aktuell.map((k) => (k.i === id ? { ...k, tage } : k));
async function setzeZeitraum(kennung, tage) {
const geaendert = aktuell.map((k) => (k.i === kennung ? { ...k, tage } : k));
if (bearbeiten) {
setEntwurf(geaendert);
return;
}
setLayout(geaendert);
if (erzwungen) return;
setDashboards((alt) => alt.map((d) => (
d.id === aktives.id ? { ...d, layout: geaendert } : d
)));
if (erzwungen || !aktives?.id) return;
try {
await api.saveDashboardLayout(schlank(geaendert));
await api.updateDashboard(aktives.id, { layout: geaendert });
} catch {
/* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */
}
}
const eigenes = herkunft === "user" && aktives?.id;
return (
<div>
<div className="page-head">
<div>
<h1>Übersicht</h1>
<h1>{aktives?.name ?? "Übersicht"}</h1>
<div className="sub">
{erzwungen
? "Die Anordnung ist vom Administrator vorgegeben."
@@ -199,25 +308,39 @@ export default function Dashboard() {
</div>
<div className="actions">
{!bearbeiten && !erzwungen && (
<button className="btn" onClick={starteBearbeitung}>
<Icon name="edit" size={16} />Anordnen
</button>
<>
<button className="btn" onClick={starteBearbeitung}>
<Icon name="edit" size={16} />Anordnen
</button>
<button className="btn ghost" onClick={anlegen}>
<Icon name="plus" size={16} />Neues Dashboard
</button>
{eigenes && (
<>
<button className="btn ghost" onClick={umbenennen}>Umbenennen</button>
{dashboards.length > 1 && (
<button className="btn ghost danger" onClick={loeschen}>
<Icon name="trash" size={15} />
</button>
)}
</>
)}
</>
)}
{bearbeiten && (
<>
<div className="menu-wrap">
<button className="btn" onClick={() => setMenuOffen((o) => !o)}
disabled={verfuegbar.length === 0}>
<button className="btn" onClick={() => setMenuOffen((o) => !o)}>
<Icon name="plus" size={16} />Karte hinzufügen
</button>
{menuOffen && (
<div className="menu karten-menu">
{verfuegbar.map((id) => (
<button key={id} className="karten-wahl" onClick={() => hinzufuegen(id)}>
<Vorschau art={KARTEN[id].vorschau} />
{KARTEN_IDS.map((art) => (
<button key={art} className="karten-wahl" onClick={() => hinzufuegen(art)}>
<Vorschau art={KARTEN[art].vorschau} />
<span className="karten-text">
<span className="karten-titel">{KARTEN[id].titel}</span>
<span className="muted small">{KARTEN[id].beschreibung}</span>
<span className="karten-titel">{KARTEN[art].titel}</span>
<span className="muted small">{KARTEN[art].beschreibung}</span>
</span>
</button>
))}
@@ -240,7 +363,8 @@ export default function Dashboard() {
<Icon name="edit" size={16} />
<span>
Karten ziehen zum Verschieben, an der rechten unteren Ecke größer ziehen.
Änderungen gelten erst nach Speichern.
Dieselbe Karte lässt sich mehrfach hinzufügen. Änderungen gelten erst
nach Speichern.
</span>
</div>
)}
@@ -259,14 +383,15 @@ export default function Dashboard() {
compactType="vertical"
>
{aktuell.map((eintrag) => {
const karte = KARTEN[eintrag.i];
const karte = KARTEN[eintrag.type];
if (!karte) return null;
const Inhalt = karte.komponente;
const artikel = produkte.find((p) => p.id === eintrag.props?.product_id);
return (
<section key={eintrag.i} className="card dash-card">
<div className="dash-card-head">
<Icon name={karte.icon} size={16} />
<h2>{karte.titel}</h2>
<h2>{artikel ? `${karte.titel}: ${artikel.name}` : karte.titel}</h2>
{/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe
dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */}
{karte.zeitraum && (
@@ -278,6 +403,18 @@ export default function Dashboard() {
))}
</select>
)}
{/* Artikelbezogene Karten werden beim Anordnen eingestellt -
so kann dieselbe Art mehrfach auf verschiedene Artikel zeigen. */}
{bearbeiten && karte.artikel && (
<select className="zeitraum" value={eintrag.props?.product_id ?? ""}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => setzeProps(eintrag.i, {
product_id: e.target.value ? Number(e.target.value) : undefined,
})}>
<option value=""> Artikel wählen </option>
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
{bearbeiten && (
<button className="btn-icon danger" title="Karte entfernen"
onMouseDown={(e) => e.stopPropagation()}
@@ -287,7 +424,7 @@ export default function Dashboard() {
)}
</div>
<div className="dash-card-body">
<Inhalt tage={eintrag.tage ?? karte.zeitraum} />
<Inhalt tage={eintrag.tage ?? karte.zeitraum} props={eintrag.props} />
</div>
</section>
);