diff --git a/web/src/dashboard/KartenConfig.jsx b/web/src/dashboard/KartenConfig.jsx
new file mode 100644
index 0000000..8088c78
--- /dev/null
+++ b/web/src/dashboard/KartenConfig.jsx
@@ -0,0 +1,93 @@
+import Icon from "../components/Icon";
+import { ZEITRAEUME } from "./cards";
+import { locationOptions } from "../locationPath";
+
+/**
+ * Konfig-Dialog einer Karte – wie in HomeAssistant: ein eigener Dialog mit den
+ * Optionen der Karte, statt Dropdowns im Kartenkopf. Welche Felder erscheinen,
+ * bestimmt `karte.config`. Die Werte landen in den Karten-Props (bzw. `tage`
+ * beim Zeitraum) und werden mit dem Layout gespeichert.
+ */
+export default function KartenConfig({
+ karte, props: karteProps = {}, tage,
+ produkte = [], locations = [], onProps, onTage, onClose,
+}) {
+ const felder = karte?.config || [];
+
+ function feld(f) {
+ if (f.type === "product") {
+ return (
+
+ {f.label}
+ onProps({ [f.key]: e.target.value ? Number(e.target.value) : undefined })}>
+ – Artikel wählen –
+ {produkte.map((p) => {p.name} )}
+
+
+ );
+ }
+ if (f.type === "location") {
+ return (
+
+ {f.label}{f.hint ? ({f.hint}) : null}
+ onProps({ [f.key]: e.target.value || undefined })}>
+ – alle Orte –
+ {locationOptions(locations).map((o) => {o.label} )}
+
+
+ );
+ }
+ if (f.type === "zeitraum") {
+ return (
+
+ {f.label}
+ onTage(Number(e.target.value))}>
+ {ZEITRAEUME.map((z) => {z.label} )}
+
+
+ );
+ }
+ if (f.type === "select") {
+ return (
+
+ {f.label}
+ onProps({ [f.key]: e.target.value })}>
+ {(f.optionen || []).map((o) => {o.label} )}
+
+
+ );
+ }
+ if (f.type === "boolean") {
+ return (
+
+ onProps({ [f.key]: e.target.checked })} />
+ {f.label}
+
+ );
+ }
+ return null;
+ }
+
+ return (
+
e.target === e.currentTarget && onClose()}>
+
+
+
+
{karte?.titel} einstellen
+
+
+ {felder.length === 0
+ ?
Diese Karte hat keine Einstellungen.
+ : felder.map(feld)}
+
+
+ Fertig
+
+
+
+ );
+}
diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx
index 5226356..a04a78f 100644
--- a/web/src/dashboard/cards.jsx
+++ b/web/src/dashboard/cards.jsx
@@ -306,43 +306,74 @@ function KarteAblauf({ modus = "alle" }) {
);
}
-function KarteEinkaufsliste() {
+/**
+ * Einkaufsliste mit Gesamt-Bedarf UND Bedarf je Lagerort. Über die Karten-Konfig
+ * lässt sich ein Lagerort festlegen: leer = alle Orte, sonst nur dieser.
+ */
+function KarteEinkaufsliste({ props: karteProps }) {
+ const ortId = karteProps?.location_id || "";
const { daten, fehler } = useDaten(async () => {
- const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
- return { produkte, gruppen };
+ const [produkte, gruppen, orte] = await Promise.all([
+ api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(),
+ ]);
+ return { produkte, gruppen, orte };
});
if (fehler) return {fehler}
;
if (!daten) return Lädt…
;
- if (!daten.produkte.length && !daten.gruppen.length) {
- return Alle Mindestbestände erreicht.
;
- }
- return (
+ const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
+ const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
+ const orteLeer = orte.every((o) => !o.products.length && !o.groups.length);
+ if (gesamtLeer && orteLeer) return Alle Mindestbestände erreicht.
;
+
+ // Ein Abschnitt (Gesamt oder ein Ort). Die Produkt-Formatierung kommt herein,
+ // weil Gesamt-Produkte Gebinde/Basiseinheit tragen, je-Ort-Bedarfe aber schon
+ // eine fertige Einheit (unit_label).
+ const abschnitt = (gruppen, produkte, prodWert) => (
-
- Bedarf Bestand Fehlt
-
+ Bedarf Bestand Fehlt
- {daten.gruppen.map((it) => (
+ {gruppen.map((it) => (
Gruppe {it.name}
{fmt(it.stock)} {it.unit_name}
{fmt(it.deficit)} {it.unit_name}
))}
- {daten.produkte.map((it) => (
+ {produkte.map((it) => (
{it.name}
- {amountText(it.stock, it.package_size, it.base_unit)}
- {amountText(it.deficit, it.package_size, it.base_unit)}
+ {prodWert(it, "stock")}
+ {prodWert(it, "deficit")}
))}
);
+ const gesamtProd = (it, feld) => amountText(it[feld], it.package_size, it.base_unit);
+ const ortProd = (it, feld) => `${fmt(it[feld])} ${it.unit_label}`;
+
+ return (
+
+ {!gesamtLeer && (
+
+
Gesamt
+ {abschnitt(daten.gruppen, daten.produkte, gesamtProd)}
+
+ )}
+ {orte.map((o) => ((o.products.length || o.groups.length) ? (
+
+
+ {o.location_name}
+
+ {abschnitt(o.groups, o.products, ortProd)}
+
+ ) : null))}
+
+ );
}
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
@@ -575,7 +606,7 @@ export const KARTEN = {
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,
+ config: [{ key: "product_id", type: "product", label: "Artikel" }],
komponente: KarteArtikel,
},
status: {
@@ -618,6 +649,7 @@ export const KARTEN = {
titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle",
beschreibung: "Produkte und Gruppen unter Mindestbestand",
standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
+ config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
komponente: KarteEinkaufsliste,
},
movements: {
@@ -648,19 +680,24 @@ export const KARTEN = {
titel: "Bestandsverlauf", icon: "history", vorschau: "linie",
beschreibung: "Artikeleinheiten im Lager über 90 Tage",
zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
+ config: [{ type: "zeitraum", label: "Zeitraum" }],
komponente: KarteBestandsverlauf,
},
"product-timeline": {
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
beschreibung: "Ein fester Artikel über die Zeit – mehrfach verwendbar",
zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
- artikel: true,
+ config: [
+ { key: "product_id", type: "product", label: "Artikel" },
+ { type: "zeitraum", label: "Zeitraum" },
+ ],
komponente: KarteArtikelverlauf,
},
"activity-timeline": {
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall",
beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung",
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 },
+ config: [{ type: "zeitraum", label: "Zeitraum" }],
komponente: KarteBewegungsverlauf,
},
};
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx
index 6f6f373..33eb1c9 100644
--- a/web/src/pages/Dashboard.jsx
+++ b/web/src/pages/Dashboard.jsx
@@ -6,7 +6,8 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
-import { KARTEN, KARTEN_IDS, ZEITRAEUME } from "../dashboard/cards";
+import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
+import KartenConfig from "../dashboard/KartenConfig";
import Vorschau from "../dashboard/Vorschau";
const Raster = WidthProvider(GridLayout);
@@ -54,6 +55,8 @@ export default function Dashboard() {
const [fehler, setFehler] = useState(null);
const [menuOffen, setMenuOffen] = useState(false);
const [produkte, setProdukte] = useState([]);
+ const [locations, setLocations] = useState([]);
+ const [konfigKarte, setKonfigKarte] = useState(null); // Kennung der Karte im Konfig-Dialog
const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id;
const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0];
@@ -80,14 +83,15 @@ export default function Dashboard() {
useEffect(() => { laden(); }, []);
- // Artikelliste nur einmal für die Einstellung der artikelbezogenen Karten.
+ // Artikel- und Lagerortliste einmal für die Karten-Konfiguration laden.
useEffect(() => {
api.listProducts().then(setProdukte).catch(() => {});
+ api.listLocations().then(setLocations).catch(() => {});
}, []);
// Beim Wechsel des Dashboards einen offenen Entwurf verwerfen – sonst
// landete er beim Speichern auf dem falschen.
- useEffect(() => { setEntwurf(null); setMenuOffen(false); }, [aktivesId]);
+ useEffect(() => { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }, [aktivesId]);
/**
* Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt.
@@ -149,6 +153,7 @@ export default function Dashboard() {
const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) });
setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d)));
setEntwurf(null);
+ setKonfigKarte(null);
toast("Anordnung gespeichert.");
} catch (err) {
setFehler(err.message);
@@ -271,26 +276,10 @@ export default function Dashboard() {
});
}
- /**
- * Zeitraum einer Verlaufskarte ändern. Außerhalb des Bearbeitungsmodus wird
- * 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(kennung, tage) {
- const geaendert = aktuell.map((k) => (k.i === kennung ? { ...k, tage } : k));
- if (bearbeiten) {
- setEntwurf(geaendert);
- return;
- }
- setDashboards((alt) => alt.map((d) => (
- d.id === aktives.id ? { ...d, layout: geaendert } : d
- )));
- if (erzwungen || !aktives?.id) return;
- try {
- await api.updateDashboard(aktives.id, { layout: geaendert });
- } catch {
- /* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */
- }
+ /** Zeitraum einer Karte ändern. Passiert nur noch im Konfig-Dialog, also im
+ * Bearbeitungsmodus – gespeichert wird mit „Speichern" wie jede andere Änderung. */
+ function setzeZeitraum(kennung, tage) {
+ setEntwurf((alt) => (alt ?? []).map((k) => (k.i === kennung ? { ...k, tage } : k)));
}
const eigenes = herkunft === "user" && aktives?.id;
@@ -348,7 +337,8 @@ export default function Dashboard() {
)}
Speichern
- { setEntwurf(null); setMenuOffen(false); }}>
+ { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }}>
Abbrechen
>
@@ -387,33 +377,24 @@ export default function Dashboard() {
if (!karte) return null;
const Inhalt = karte.komponente;
const artikel = produkte.find((p) => p.id === eintrag.props?.product_id);
+ const ort = eintrag.props?.location_id
+ ? locations.find((l) => l.id === eintrag.props.location_id)
+ : null;
+ const zusatz = artikel?.name || ort?.name; // im Titel gezeigt
return (
-
{artikel ? `${karte.titel}: ${artikel.name}` : karte.titel}
- {/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe
- dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */}
- {karte.zeitraum && (
- {zusatz ? `${karte.titel}: ${zusatz}` : karte.titel}
+ {/* Einstellungen sammelt der Konfig-Dialog (wie in HomeAssistant),
+ statt einzelner Dropdowns im Kopf. Nur beim Anordnen sichtbar
+ und nur, wenn die Karte überhaupt Optionen hat. */}
+ {bearbeiten && karte.config && (
+ e.stopPropagation()}
- onChange={(e) => setzeZeitraum(eintrag.i, Number(e.target.value))}>
- {ZEITRAEUME.map((z) => (
- {z.label}
- ))}
-
- )}
- {/* Artikelbezogene Karten werden beim Anordnen eingestellt -
- so kann dieselbe Art mehrfach auf verschiedene Artikel zeigen. */}
- {bearbeiten && karte.artikel && (
- e.stopPropagation()}
- onChange={(e) => setzeProps(eintrag.i, {
- product_id: e.target.value ? Number(e.target.value) : undefined,
- })}>
- – Artikel wählen –
- {produkte.map((p) => {p.name} )}
-
+ onClick={() => setKonfigKarte(eintrag.i)}>
+
+
)}
{bearbeiten && (
)}
+
+ {konfigKarte && (() => {
+ const inst = aktuell.find((k) => k.i === konfigKarte);
+ if (!inst) return null;
+ const karte = KARTEN[inst.type];
+ return (
+ setzeProps(konfigKarte, partial)}
+ onTage={(n) => setzeZeitraum(konfigKarte, n)}
+ onClose={() => setKonfigKarte(null)}
+ />
+ );
+ })()}
);
}