Vorrat: eigene Läden mit Produktzuordnung

- Läden pro Liste (Tabelle stores): anlegen, umbenennen, sortieren,
  löschen; je Laden eigene Reihenfolge der Abteilungen
- Produkte einem Laden zuordnen (Tabelle store_products, pro Liste,
  bleibt nach dem Einkauf gemerkt); gelöschter Laden → Produkte ohne Laden
- Einkaufsliste "Nach Laden": nach Laden gruppiert, darin nach Abteilung;
  Filter "Alle | Laden | Ohne Laden", im Filter Hinzugefügtes wird diesem
  Laden zugeordnet
- Läden fremder privater Listen sind nicht nutzbar (Tests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 20:11:39 +02:00
1 parent bbf740603e
commit fef9c8f16e
9 files changed
+393 -33

No files matched your search

@@ -1,7 +1,7 @@
import { useState } from 'react';
// Reihenfolge der Abteilungen so, wie man durch den eigenen Laden läuft (gilt für die gewählte Liste)
export default function CategoryOrderDialog({ ref, categories, order, onSave, busy }) {
export default function CategoryOrderDialog({ ref, categories, order, title: heading, onSave, busy }) {
const [current, setCurrent] = useState(order);
const title = Object.fromEntries(categories.map((c) => [c.key, c.title]));
const move = (index, delta) => setCurrent((list) => {
@@ -17,8 +17,8 @@ export default function CategoryOrderDialog({ ref, categories, order, onSave, bu
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Reihenfolge im Laden</h2>
<p className="hint">So sortiert „Nach Laden“ die Einkaufsliste – am besten in der Reihenfolge, in der du durch den Markt gehst.</p>
<h2>{heading || 'Reihenfolge der Abteilungen'}</h2>
<p className="hint">{heading ? 'Gilt für diesen Laden.' : 'Gilt für Produkte ohne Laden und für Läden ohne eigene Reihenfolge.'} Am besten so, wie du durch den Markt gehst.</p>
<ol className="order-list">
{current.map((key, i) => (
<li key={key}>
+72 -23
View File
@@ -13,8 +13,9 @@ export const LISTS = {
const same = (a, b) => a.toLocaleLowerCase('de-DE') === b.toLocaleLowerCase('de-DE');
/**
* extras: { minimums, categories, categoryOrder, sortMode, onSortMode, onEditOrder, onEditMinimums, onCategory }
* – Mindestbestand (Vorrat) und Laden-Sortierung nach Kategorien (Einkauf).
* extras: { minimums, categories, categoryOrder, stores, storeFilter, sortMode, onSortMode, onStoreFilter, onEditOrder,
* onEditStores, onEditMinimums, onCategory, onStore }
* – Mindestbestand (Vorrat); Einkauf "Nach Laden": nach Laden gruppiert (falls angelegt), darin nach Abteilung.
*/
export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) {
const { title, lead, empty } = LISTS[list];
@@ -25,12 +26,23 @@ export default function ListPanel({ list, items, products, find, actions, busy,
const byShop = list === 'einkauf' && extras.sortMode === 'laden';
const minimumOf = (item) => extras.minimums.find((m) => same(m.name, item.name) && m.unit === item.unit);
const categoryTitle = Object.fromEntries(extras.categories.map((c) => [c.key, c.title]));
// Laden-Modus: nach Kategorien in der Reihenfolge dieser Liste gruppieren
const groups = byShop
? extras.categoryOrder
.map((key) => ({ key, title: categoryTitle[key], items: open.filter((i) => i.category === key) }))
.filter((g) => g.items.length)
: [{ key: 'alle', items: open }];
// Laden-Modus: erst nach Laden (falls welche angelegt sind), darin nach Abteilung in der Reihenfolge des Ladens
const stores = extras.stores;
const byCategory = (list, order) => order
.map((key) => ({ key, title: categoryTitle[key], items: list.filter((i) => i.category === key) }))
.filter((g) => g.items.length);
const known = new Set(stores.map((s) => s.id));
const sections = !byShop
? [{ key: 'alle', groups: [{ key: 'alle', items: open }] }]
: !stores.length
? [{ key: 'alle', groups: byCategory(open, extras.categoryOrder) }]
: [
...stores.map((s) => ({ key: `s${s.id}`, id: s.id, title: s.name, groups: byCategory(open.filter((i) => i.storeId === s.id), s.categoryOrder) })),
{ key: 'ohne', id: 'ohne', title: 'Ohne Laden', groups: byCategory(open.filter((i) => !known.has(i.storeId)), extras.categoryOrder) },
]
.filter((sec) => extras.storeFilter === 'alle' || extras.storeFilter === sec.id)
.filter((sec) => sec.groups.length);
const count = (id) => open.filter((i) => (id === 'ohne' ? !known.has(i.storeId) : i.storeId === id)).length;
const row = (item) => (
<ItemRow
key={item.id}
@@ -43,6 +55,8 @@ export default function ListPanel({ list, items, products, find, actions, busy,
onEditMinimum={() => extras.onEditMinimums(item)}
categories={byShop ? extras.categories : null}
onCategory={extras.onCategory}
stores={byShop && stores.length ? stores : null}
onStore={extras.onStore}
/>
);
@@ -82,9 +96,25 @@ export default function ListPanel({ list, items, products, find, actions, busy,
<button key={key} type="button" aria-pressed={extras.sortMode === key} onClick={() => extras.onSortMode(key)}>{label}</button>
))}
</div>
{byShop && <button className="link-btn" type="button" onClick={extras.onEditOrder}>⇅ Reihenfolge</button>}
{byShop && (
<span className="household-tool-links">
<button className="link-btn" type="button" onClick={extras.onEditStores}>🏪 Läden{stores.length ? ` (${stores.length})` : ''}</button>
<button className="link-btn" type="button" onClick={() => extras.onEditOrder(null)}>⇅ Abteilungen</button>
</span>
)}
</div>
)}
{byShop && stores.length > 0 && (
<ul className="chip-list store-filter" aria-label="Nur einen Laden zeigen">
{[{ id: 'alle', name: 'Alle' }, ...stores, { id: 'ohne', name: 'Ohne Laden' }].map((s) => (
<li key={s.id}>
<button type="button" className={`chip${extras.storeFilter === s.id ? ' active' : ''}`} aria-pressed={extras.storeFilter === s.id} onClick={() => extras.onStoreFilter(s.id)}>
{s.name}{s.id !== 'alle' && <small> {count(s.id)}</small>}
</button>
</li>
))}
</ul>
)}
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
@@ -92,9 +122,12 @@ export default function ListPanel({ list, items, products, find, actions, busy,
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{groups.map((g) => [
byShop && <li key={`kat-${g.key}`} className="household-group-head">{g.title}</li>,
...g.items.map(row),
{sections.map((sec) => [
sec.title && <li key={`laden-${sec.key}`} className="household-store-head">{sec.title}</li>,
...sec.groups.map((g) => [
byShop && <li key={`kat-${sec.key}-${g.key}`} className="household-group-head">{g.title}</li>,
...g.items.map(row),
]),
])}
{inCart.length > 0 && (
<li className="household-cart-head" aria-hidden="true">Im Wagen</li>
@@ -205,7 +238,7 @@ function QuantityInput({ item, label, onCommit, disabled }) {
);
}
function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory }) {
function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory, stores, onStore }) {
const isShopping = list === 'einkauf';
const step = STEP[item.unit];
const last = item.quantity - step <= 0;
@@ -245,16 +278,32 @@ function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, cat
)}
✎ {personName(item.updatedBy)}
</small>
{categories && (
<select
className="unit-select category-select"
value={item.category}
onChange={(e) => onCategory(item.name, e.target.value)}
disabled={busy}
aria-label={`Kategorie von ${item.name}`}
>
{categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
{(categories || stores) && (
<span className="item-selects">
{categories && (
<select
className="unit-select category-select"
value={item.category}
onChange={(e) => onCategory(item.name, e.target.value)}
disabled={busy}
aria-label={`Kategorie von ${item.name}`}
>
{categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
)}
{stores && (
<select
className="unit-select category-select"
value={stores.some((s) => s.id === item.storeId) ? item.storeId : ''}
onChange={(e) => onStore(item.name, e.target.value ? Number(e.target.value) : null)}
disabled={busy}
aria-label={`Laden für ${item.name}`}
>
<option value="">Kein Laden</option>
{stores.map((s) => <option key={s.id} value={s.id}>🏪 {s.name}</option>)}
</select>
)}
</span>
)}
</div>
@@ -0,0 +1,65 @@
import { useState } from 'react';
/**
* Läden der gewählten Liste verwalten: anlegen, umbenennen, sortieren, löschen und je Laden die
* Reihenfolge der Abteilungen (jeder Markt ist anders aufgebaut). Alle Aktionen liefern den neuen Stand.
*/
export default function StoresDialog({ ref, stores, onCreate, onRename, onOrder, onDelete, onEditOrder, busy }) {
const [name, setName] = useState('');
const [names, setNames] = useState({}); // Entwürfe beim Umbenennen
async function add(e) {
e.preventDefault();
if (name.trim() && await onCreate(name.trim())) setName('');
}
function rename(store) {
const draft = names[store.id];
if (draft != null && draft.trim() && draft.trim() !== store.name) onRename(store, draft.trim());
setNames((n) => ({ ...n, [store.id]: undefined }));
}
function move(index, delta) {
const ids = stores.map((s) => s.id);
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
onOrder(ids);
}
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Läden</h2>
<p className="hint">Produkte lassen sich einem Laden zuordnen – „Nach Laden“ zeigt dann, was du wo kaufst. Die Zuordnung gilt für diese Liste und bleibt gemerkt.</p>
{stores.length > 0 && (
<ol className="order-list stores-list">
{stores.map((s, i) => (
<li key={s.id}>
<input
value={names[s.id] ?? s.name}
onChange={(e) => setNames((n) => ({ ...n, [s.id]: e.target.value }))}
onBlur={() => rename(s)}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
maxLength={40}
aria-label={`Name von ${s.name}`}
/>
<button className="link-btn" type="button" onClick={() => onEditOrder(s)} title="Reihenfolge der Abteilungen in diesem Laden">⇅ Abteilungen</button>
<button className="link-btn" type="button" disabled={busy || i === 0} onClick={() => move(i, -1)} aria-label={`${s.name} nach oben`}>↑</button>
<button className="link-btn" type="button" disabled={busy || i === stores.length - 1} onClick={() => move(i, 1)} aria-label={`${s.name} nach unten`}>↓</button>
<button className="link-btn danger" type="button" disabled={busy} aria-label={`${s.name} löschen`}
onClick={() => window.confirm(`Laden „${s.name}“ löschen? Die Produkte bleiben auf der Liste, nur ohne Laden.`) && onDelete(s)}>✕</button>
</li>
))}
</ol>
)}
<form className="household-add" onSubmit={add}>
<label className="sr-only" htmlFor="store-name">Neuer Laden</label>
<input id="store-name" value={name} onChange={(e) => setName(e.target.value)} maxLength={40} placeholder="Neuer Laden, z. B. Aldi, dm, Baumarkt" />
<button className="btn" type="submit" disabled={busy || !name.trim()}>Anlegen</button>
</form>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}
+44 -5
View File
@@ -8,6 +8,7 @@ import ListSwitcher from '../components/haushalt/ListSwitcher.jsx';
import MinimumDialog from '../components/haushalt/MinimumDialog.jsx';
import ProductsDialog from '../components/haushalt/ProductsDialog.jsx';
import RecipeCheck from '../components/haushalt/RecipeCheck.jsx';
import StoresDialog from '../components/haushalt/StoresDialog.jsx';
import { isCalm } from '../motion.js';
import useLiveData from '../useLiveData.js';
@@ -16,6 +17,7 @@ const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LIST_KEY = 'p42.haushaltListe';
const SORT_KEY = 'p42.einkaufSortierung';
const STORE_KEY = 'p42.ladenFilter';
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
@@ -42,6 +44,10 @@ export default function Haushalt() {
const [orderDialog, setOrderDialog] = useState(null);
const minRef = useRef(null);
const orderRef = useRef(null);
const storesRef = useRef(null);
const [storeFilter, setStoreFilter] = useState(() => {
try { const v = localStorage.getItem(STORE_KEY); return v && v !== 'alle' && v !== 'ohne' ? Number(v) : v || 'alle'; } catch { return 'alle'; }
});
const listDialogRef = useRef(null);
// Jede Änderung liefert den aktuellen Stand aller Listen zurück.
@@ -103,11 +109,17 @@ export default function Haushalt() {
requestAnimationFrame(() => minRef.current?.showModal());
}
function openOrder() {
setOrderDialog({ opened: Date.now() });
// store: Abteilungs-Reihenfolge eines Ladens; null: Standard der Liste
function openOrder(store) {
setOrderDialog({ store, opened: Date.now() });
requestAnimationFrame(() => orderRef.current?.showModal());
}
function chooseStoreFilter(value) {
setStoreFilter(value);
try { localStorage.setItem(STORE_KEY, String(value)); } catch { /* ignorieren */ }
}
const setCategory = (name, category) => run('PUT', '/api/haushalt/produkte/kategorie', { name, category });
const items = data?.items || [];
@@ -122,8 +134,15 @@ export default function Haushalt() {
// Alle Zeilen eines Produkts (ein Produkt kann in mehreren Einheiten auf einer Liste stehen)
const find = (list, name) => byList[list].filter((i) => i.name.toLocaleLowerCase('de-DE') === name.toLocaleLowerCase('de-DE'));
const stores = current?.stores || [];
// Gewählter Laden-Filter; gibt es den Laden nicht (mehr), alle zeigen
const activeStore = storeFilter === 'alle' || storeFilter === 'ohne' || stores.some((s) => s.id === storeFilter) ? storeFilter : 'alle';
const actions = {
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit, listId: current?.id }),
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', {
list, name, quantity, unit, listId: current?.id,
// Im Laden-Filter hinzugefügt: gleich diesem Laden zuordnen
storeId: list === 'einkauf' && sortMode === 'laden' && typeof activeStore === 'number' ? activeStore : undefined,
}),
update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch),
move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`),
check: (item, checked) => run('PATCH', `/api/haushalt/items/${item.id}`, { checked }, { animate: true }),
@@ -204,6 +223,11 @@ export default function Haushalt() {
minimums: (data.minimums || []).filter((m) => m.listId === current.id),
categories: data.categories || [],
categoryOrder: current.categoryOrder || [],
stores,
storeFilter: activeStore,
onStoreFilter: chooseStoreFilter,
onEditStores: () => storesRef.current?.showModal(),
onStore: (name, storeId) => run('PUT', `/api/haushalt/listen/${current.id}/produkt-laden`, { name, storeId }),
sortMode,
onSortMode: chooseSort,
onEditOrder: openOrder,
@@ -253,9 +277,24 @@ export default function Haushalt() {
key={orderDialog.opened}
ref={orderRef}
categories={data?.categories || []}
order={current.categoryOrder || []}
title={orderDialog.store ? `Abteilungen bei ${orderDialog.store.name}` : null}
order={(orderDialog.store ? stores.find((s) => s.id === orderDialog.store.id)?.categoryOrder : current.categoryOrder) || []}
busy={busy}
onSave={(order) => run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order })}
onSave={(order) => (orderDialog.store
? run('PATCH', `/api/haushalt/laeden/${orderDialog.store.id}`, { categoryOrder: order })
: run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order }))}
/>
)}
{current && (
<StoresDialog
ref={storesRef}
stores={stores}
busy={busy}
onCreate={(name) => run('POST', `/api/haushalt/listen/${current.id}/laeden`, { name })}
onRename={(store, name) => run('PATCH', `/api/haushalt/laeden/${store.id}`, { name })}
onOrder={(order) => run('PUT', `/api/haushalt/listen/${current.id}/laeden-reihenfolge`, { order })}
onDelete={(store) => run('DELETE', `/api/haushalt/laeden/${store.id}`)}
onEditOrder={openOrder}
/>
)}
</>
+10
View File
@@ -310,3 +310,13 @@ html.vt-items .household-item { view-transition-name: var(--vt); }
.recipe-score { font-variant-numeric: tabular-nums; color: var(--warn); font-size: 0.82rem; }
.recipe-saved li.complete .recipe-score { color: var(--ok); }
.recipe-missing { flex: 1 1 200px; color: var(--muted); }
/* Läden */
.household-tool-links { display: inline-flex; gap: 14px; }
.store-filter { margin-top: -4px; }
.store-filter small { opacity: 0.7; }
.household-store-head { margin: 16px 0 4px; padding: 6px 10px; font-weight: 700; color: var(--bright); background: var(--accent-soft); border-left: 3px solid var(--accent); border-radius: var(--radius-s); }
.household-store-head:first-child { margin-top: 0; }
.stores-list input { flex: 1; min-width: 0; }
.item-selects { display: flex; flex-wrap: wrap; gap: 4px 8px; }
.item-selects .category-select { margin-top: 2px; }