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:
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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user