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,6 @@
import { useEffect, useState } from "react";
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { api } from "./api";
import { useAuth } from "./auth";
import Icon from "./components/Icon";
import BrandMark from "./components/BrandMark";
@@ -33,6 +35,19 @@ function NavItem({ to, icon, label, end }) {
function Sidebar() {
const { user, isAdmin, logout } = useAuth();
const navigate = useNavigate();
const [dashboards, setDashboards] = useState([]);
// Die Dashboards des Benutzers stehen als eigene Eintraege in der Leiste.
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
// das Ereignis, das die Dashboard-Seite dabei ausloest.
useEffect(() => {
function laden() {
api.dashboards().then((a) => setDashboards(a.dashboards)).catch(() => {});
}
laden();
window.addEventListener("dashboards-geaendert", laden);
return () => window.removeEventListener("dashboards-geaendert", laden);
}, []);
function handleLogout() {
logout();
@@ -46,6 +61,14 @@ function Sidebar() {
</div>
<nav className="sidebar-nav">
<NavItem to="/" end icon="dashboard" label="Übersicht" />
{/* Ab dem zweiten Dashboard lohnt die Aufzaehlung; bei einem einzigen
waere sie nur eine Verdopplung von "Übersicht". */}
{dashboards.length > 1 && dashboards.map((d, index) => (
<NavLink key={d.id} to={index === 0 ? "/" : `/d/${d.id}`} end
className={({ isActive }) => (isActive ? "nav-link sub active" : "nav-link sub")}>
<span className="label">{d.name}</span>
</NavLink>
))}
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" />
@@ -103,6 +126,7 @@ export default function App() {
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route path="/" element={<Protected><Dashboard /></Protected>} />
<Route path="/d/:id" element={<Protected><Dashboard /></Protected>} />
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/products" element={<Protected><Products /></Protected>} />

View File

@@ -163,12 +163,14 @@ export const api = {
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
// Startseite: Anordnung und Auswertungen
dashboardLayout: () => request("/dashboard/layout"),
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: { layout } }),
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
saveDashboardDefault: (layout) =>
request("/dashboard/layout/default", { method: "PUT", body: { layout } }),
// Startseite: Dashboards und Auswertungen
dashboards: () => request("/dashboard/layouts"),
createDashboard: (body) => request("/dashboard/layouts", { method: "POST", body }),
updateDashboard: (id, body) =>
request(`/dashboard/layouts/${id}`, { method: "PATCH", body }),
deleteDashboard: (id) => request(`/dashboard/layouts/${id}`, { method: "DELETE" }),
resetDashboards: () => request("/dashboard/layouts", { method: "DELETE" }),
saveDashboardDefault: () => request("/dashboard/layouts/default", { method: "PUT" }),
setDashboardEnforced: (value) =>
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
dashboardStats: () => request("/dashboard/stats"),

View File

@@ -13,16 +13,25 @@ import Icon from "./components/Icon";
*
* const confirm = useConfirm();
* if (!(await confirm({ title: "Gruppe löschen?", message: "…" }))) return;
*
* Mit `prompt: true` wird daraus eine Eingabe; aufgeloest wird dann der
* eingetippte Text (oder `null` beim Abbrechen). Auch dafuer gilt der Grund von
* oben: `window.prompt` sieht aus wie der Browser, nicht wie diese Anwendung.
*
* const name = await confirm({ title: "Name?", prompt: true, defaultValue: "…" });
*/
const ConfirmContext = createContext(async () => false);
export function ConfirmProvider({ children }) {
const [frage, setFrage] = useState(null);
const [eingabe, setEingabe] = useState("");
// Die Zusage des offenen Dialogs, damit `await confirm(...)` funktioniert.
const antwort = useRef(null);
const confirm = useCallback((optionen) => {
setFrage(typeof optionen === "string" ? { message: optionen } : optionen);
const gestellt = typeof optionen === "string" ? { message: optionen } : optionen;
setFrage(gestellt);
setEingabe(gestellt.defaultValue ?? "");
return new Promise((resolve) => {
antwort.current = resolve;
});
@@ -34,25 +43,46 @@ export function ConfirmProvider({ children }) {
antwort.current = null;
}
/** Bei einer Eingabe zaehlt der Text, sonst wie bisher true/false. */
function bestaetigen() {
if (!frage?.prompt) { schliessen(true); return; }
const text = eingabe.trim();
if (!text) return; // Leer bestaetigen ergibt keinen Sinn
schliessen(text);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{frage && (
<div className="modal-backdrop"
onClick={(e) => e.target === e.currentTarget && schliessen(false)}>
onClick={(e) => e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head">
<Icon name="alert" size={18} className={frage.danger ? "icon-danger" : ""} />
<Icon name={frage.prompt ? "edit" : "alert"} size={18}
className={frage.danger ? "icon-danger" : ""} />
<h2>{frage.title || "Bitte bestätigen"}</h2>
</div>
<p className="modal-body">{frage.message}</p>
{frage.message && <p className="modal-body">{frage.message}</p>}
{frage.prompt && (
<div className="modal-body">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={eingabe} onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") bestaetigen();
if (e.key === "Escape") schliessen(null);
}} />
</div>
)}
<div className="btn-pair">
<button type="button" className="btn" autoFocus onClick={() => schliessen(false)}>
<button type="button" className="btn" autoFocus={!frage.prompt}
onClick={() => schliessen(frage.prompt ? null : false)}>
{frage.cancelLabel || "Abbrechen"}
</button>
<button type="button"
className={frage.danger ? "btn danger-solid" : "btn primary"}
onClick={() => schliessen(true)}>
disabled={frage.prompt && !eingabe.trim()}
onClick={bestaetigen}>
{frage.confirmLabel || "Fortfahren"}
</button>
</div>

View File

@@ -57,6 +57,145 @@ function zeitLabel(iso, tage) {
}
// ---------------------------------------------------------------- Aktionen
/**
* Artikelsuche mit Tastaturbedienung, wie sie die Aktionskarten brauchen.
* Erst ab zwei Zeichen wird gesucht sonst laedt jede Karte beim Oeffnen die
* halbe Artikelliste.
*/
function Artikelsuche({ gewaehlt, onWaehlen }) {
const [text, setText] = useState("");
const [treffer, setTreffer] = useState([]);
useEffect(() => {
if (gewaehlt || text.trim().length < 2) { setTreffer([]); return undefined; }
let aktiv = true;
const zeit = setTimeout(() => {
api.listProducts(text.trim())
.then((liste) => aktiv && setTreffer(liste.slice(0, 6)))
.catch(() => aktiv && setTreffer([]));
}, 250);
return () => { aktiv = false; clearTimeout(zeit); };
}, [text, gewaehlt]);
if (gewaehlt) {
return (
<div className="aktion-gewaehlt">
<span className="name">{gewaehlt.name}</span>
<button className="btn-icon" title="Andere wählen"
onClick={() => { onWaehlen(null); setText(""); }}>
<Icon name="close" size={15} />
</button>
</div>
);
}
return (
<div className="aktion-suche">
<input value={text} onChange={(e) => setText(e.target.value)}
placeholder="Artikel suchen…" onMouseDown={(e) => e.stopPropagation()} />
{treffer.length > 0 && (
<ul className="aktion-treffer">
{treffer.map((p) => (
<li key={p.id}>
<button onClick={() => { onWaehlen(p); setText(""); }}>{p.name}</button>
</li>
))}
</ul>
)}
</div>
);
}
/**
* Ein- und Auslagern ohne Seitenwechsel: Artikel suchen, Menge eintragen,
* fertig. MHD und Lagerort bleiben bewusst weg wer die braucht, ist auf der
* vollen Seite besser aufgehoben; hier zaehlt der schnelle Alltagsfall.
*/
function KarteSchnellbuchung({ richtung }) {
const einlagern = richtung === "in";
const [artikel, setArtikel] = useState(null);
const [menge, setMenge] = useState("1");
const [meldung, setMeldung] = useState(null);
const [fehler, setFehler] = useState(null);
const [laeuft, setLaeuft] = useState(false);
const zahl = Number(String(menge).replace(",", "."));
const bereit = artikel && zahl > 0 && !laeuft;
async function buchen() {
if (!bereit) return;
setLaeuft(true);
setFehler(null);
setMeldung(null);
try {
// "article" ist die Einheit, in der der Artikel gefuehrt wird (Gebinde
// oder Basiseinheit) - genau das, was in der Karte steht.
if (einlagern) {
await api.checkInBatch({
product_id: artikel.id, unit: "article", lines: [{ quantity: zahl }],
});
} else {
await api.checkOut({ product_id: artikel.id, quantity: zahl, unit: "article" });
}
setMeldung(`${fmt(zahl)} ${einlagern ? "eingelagert" : "ausgelagert"}.`);
setMenge("1");
} catch (err) {
setFehler(err.message);
} finally {
setLaeuft(false);
}
}
return (
<div className="card-stack aktion-karte">
<Artikelsuche gewaehlt={artikel} onWaehlen={(p) => {
setArtikel(p); setMeldung(null); setFehler(null);
}} />
<div className="aktion-zeile">
<input className="aktion-menge" value={menge} inputMode="decimal"
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => setMenge(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") buchen(); }} />
<button className={`btn ${einlagern ? "primary" : ""}`} disabled={!bereit} onClick={buchen}>
<Icon name={einlagern ? "checkin" : "checkout"} size={16} />
{einlagern ? "Einlagern" : "Auslagern"}
</button>
</div>
{meldung && <div className="alert ok">{meldung}</div>}
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
</div>
);
}
/**
* Ein fest eingestellter Artikel im Blick: Bestand jetzt, mit Hinweis auf den
* Mindestbestand. Welcher Artikel gemeint ist, steht in den Karten-Props und
* wird deshalb mitgespeichert.
*/
function KarteArtikel({ props: karteProps }) {
const produktId = karteProps?.product_id;
const { daten, fehler } = useDaten(
() => (produktId ? api.getProduct(produktId) : Promise.resolve(null)),
[produktId],
);
if (!produktId) {
return <div className="empty">Kein Artikel gewählt über Anordnen einstellen.</div>;
}
return (
<Zustand fehler={fehler} daten={daten}>
{daten && (
<div className="card-stack">
<div className="kpi">
<div className="kpi-label"><Icon name="package" size={14} />{daten.name}</div>
<div className="kpi-value">{articlePrimary(daten)}</div>
</div>
<div className="muted small">{articleSecondary(daten)}</div>
</div>
)}
</Zustand>
);
}
function KarteAktionen() {
return (
<div className="card-actions">
@@ -330,29 +469,24 @@ function KarteBestandsverlauf({ tage = 90 }) {
);
}
function KarteArtikelverlauf({ tage = 90 }) {
const [produkte, setProdukte] = useState([]);
const [gewaehlt, setGewaehlt] = useState("");
/**
* Verlauf eines Artikels. Welcher gemeint ist, steht in den Karten-Props und
* ist damit gespeichert - frueher lag die Wahl nur in useState und war nach
* jedem Neuladen wieder weg, weshalb sich damit nichts beobachten liess.
*/
function KarteArtikelverlauf({ tage = 90, props: karteProps }) {
const gewaehlt = karteProps?.product_id ?? "";
const { daten, fehler } = useDaten(
() => (gewaehlt ? api.dashboardTimeline(tage, gewaehlt) : Promise.resolve([])),
[gewaehlt, tage],
);
useEffect(() => {
api.listProducts().then((liste) => {
setProdukte(liste);
if (liste.length && !gewaehlt) setGewaehlt(String(liste[0].id));
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!gewaehlt) {
return <div className="empty">Kein Artikel gewählt über Anordnen einstellen.</div>;
}
return (
<div className="card-stack">
<select value={gewaehlt} onChange={(e) => setGewaehlt(e.target.value)}
style={{ marginTop: 0 }}>
<option value=""> Artikel wählen </option>
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{fehler ? (
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
) : (
@@ -405,6 +539,25 @@ export const KARTEN = {
standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
komponente: KarteAktionen,
},
"checkin-quick": {
titel: "Einlagern", icon: "checkin", vorschau: "aktion",
beschreibung: "Artikel suchen, Menge eintragen ohne Seitenwechsel",
standard: { w: 4, h: 4 }, min: { w: 3, h: 4 },
komponente: () => <KarteSchnellbuchung richtung="in" />,
},
"checkout-quick": {
titel: "Auslagern", icon: "checkout", vorschau: "aktion",
beschreibung: "Artikel suchen, Menge entnehmen ohne Seitenwechsel",
standard: { w: 4, h: 4 }, min: { w: 3, h: 4 },
komponente: () => <KarteSchnellbuchung richtung="out" />,
},
"product-stock": {
titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl",
beschreibung: "Bestand eines festen Artikels mehrfach verwendbar",
standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
artikel: true,
komponente: KarteArtikel,
},
status: {
titel: "Status", icon: "dashboard", vorschau: "kennzahlen",
beschreibung: "Bald ablaufend, abgelaufen, nachzukaufen",
@@ -479,8 +632,9 @@ export const KARTEN = {
},
"product-timeline": {
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
beschreibung: "Ein wählbarer Artikel über 90 Tage",
beschreibung: "Ein fester Artikel über die Zeit mehrfach verwendbar",
zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
artikel: true,
komponente: KarteArtikelverlauf,
},
"activity-timeline": {

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

View File

@@ -779,3 +779,63 @@ select.zeitraum:hover { color: var(--text); }
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
}
/* ---- Dashboards: Unterpunkte in der Seitenleiste ---- */
/* Eingerückt und kleiner, damit die Dashboards sichtbar zu "Übersicht"
gehören und nicht wie eigene Hauptbereiche wirken. */
.nav-link.sub {
padding-left: 34px;
font-size: 13px;
color: var(--muted);
}
.nav-link.sub.active { font-weight: 600; }
/* ---- Aktionskarten: Ein- und Auslagern ohne Seitenwechsel ---- */
.aktion-karte { gap: 10px; }
.aktion-suche { position: relative; }
.aktion-suche input { width: 100%; }
/* Trefferliste schwebt über der Karte - im Raster ist darunter kein Platz. */
.aktion-treffer {
position: absolute;
z-index: 5;
left: 0;
right: 0;
margin: 2px 0 0;
padding: 4px;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 20px rgb(0 0 0 / 12%);
max-height: 180px;
overflow-y: auto;
}
.aktion-treffer li button {
display: block;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: none;
color: var(--text);
text-align: left;
cursor: pointer;
font-size: 13px;
}
.aktion-treffer li button:hover { background: var(--surface-2); }
.aktion-gewaehlt {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--surface-2);
border-radius: 8px;
}
.aktion-gewaehlt .name { flex: 1; font-weight: 600; font-size: 14px; }
.aktion-zeile { display: flex; gap: 8px; align-items: center; }
.aktion-zeile .btn { flex: 1; justify-content: center; }
.aktion-menge { width: 72px; text-align: right; }