Vorrat: Mindestbestand, Sortierung nach Laden, Rezeptsammlung mit Vorschlägen

- Mindestbestand pro Liste/Produkt/Einheit (Tabelle stock_minimums): fällt
  der Vorrat darunter, füllt der Server die Einkaufsliste genau bis zur
  fehlenden Menge auf (nach Ändern, Aufgebraucht, Löschen, Setzen)
- Laden-Kategorien (kategorien.py): automatische Zuordnung über Stichwörter
  (längstes Stichwort gewinnt), änderbar in der Zeile und im
  Vorschläge-Dialog; Einkaufsliste "A–Z" oder "Nach Laden" gruppiert;
  Reihenfolge der Abteilungen pro Liste
- Rezeptsammlung (Tabelle recipes): "☆ Merken" beim Abgleich, "Was kann ich
  kochen?" sortiert gemerkte Rezepte nach vorhandenen Zutaten (Salz & Co.
  zählen nicht, nur sichtbare Produkte), vollständiger Abgleich per Klick
- Migration Schritt 3 (Kategorien für bestehende Produkte); _add_column für
  Tabellen, die SCHEMA in alten Datenbanken schon neu angelegt hat
- Tests in test_vorrat_extras.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:17:52 +02:00
1 parent 9df0e38f52
commit 9835f5e69e
15 files changed
+879 -40

No files matched your search

@@ -0,0 +1,39 @@
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 }) {
const [current, setCurrent] = useState(order);
const title = Object.fromEntries(categories.map((c) => [c.key, c.title]));
const move = (index, delta) => setCurrent((list) => {
const next = [...list];
[next[index], next[index + delta]] = [next[index + delta], next[index]];
return next;
});
async function save() {
if (await onSave(current)) ref.current.close();
}
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>
<ol className="order-list">
{current.map((key, i) => (
<li key={key}>
<span>{title[key]}</span>
<button className="link-btn" type="button" disabled={i === 0} onClick={() => move(i, -1)} aria-label={`${title[key]} nach oben`}>↑</button>
<button className="link-btn" type="button" disabled={i === current.length - 1} onClick={() => move(i, 1)} aria-label={`${title[key]} nach unten`}>↓</button>
</li>
))}
</ol>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => setCurrent(categories.map((c) => c.key))}>Standard</button>
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="button" disabled={busy} onClick={save}>Speichern</button>
</div>
</div>
</dialog>
);
}
+78 -8
View File
@@ -10,12 +10,41 @@ export const LISTS = {
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
export default function ListPanel({ list, items, products, find, actions, busy, hidden }) {
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).
*/
export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) {
const { title, lead, empty } = LISTS[list];
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
// Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat
const open = items.filter((i) => !i.checked);
const inCart = items.filter((i) => i.checked);
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 }];
const row = (item) => (
<ItemRow
key={item.id}
item={item}
list={list}
other={find(other, item.name)}
actions={actions}
busy={busy}
minimum={list === 'vorrat' ? minimumOf(item) : null}
onEditMinimum={() => extras.onEditMinimums(item)}
categories={byShop ? extras.categories : null}
onCategory={extras.onCategory}
/>
);
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
function hint(name) {
@@ -39,21 +68,38 @@ export default function ListPanel({ list, items, products, find, actions, busy,
<span className="hint">{lead}</span>
</header>
{list === 'vorrat' && (
<div className="household-tools">
<button className="link-btn" type="button" onClick={() => extras.onEditMinimums(null)}>
⚑ Mindestbestand{extras.minimums.length ? ` (${extras.minimums.length})` : ''}
</button>
</div>
)}
{list === 'einkauf' && (
<div className="household-tools">
<div className="sort-toggle" role="group" aria-label="Sortierung">
{[['az', 'A–Z'], ['laden', 'Nach Laden']].map(([key, label]) => (
<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>}
</div>
)}
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
{items.length === 0 ? (
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{open.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{groups.map((g) => [
byShop && <li key={`kat-${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>
)}
{inCart.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{inCart.map(row)}
</ul>
)}
@@ -159,7 +205,7 @@ function QuantityInput({ item, label, onCommit, disabled }) {
);
}
function ItemRow({ item, list, other, actions, busy }) {
function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory }) {
const isShopping = list === 'einkauf';
const step = STEP[item.unit];
const last = item.quantity - step <= 0;
@@ -184,8 +230,32 @@ function ItemRow({ item, list, other, actions, busy }) {
<strong>{item.name}</strong>
<small>
{other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `}
{minimum && (
<>
<button
type="button"
className={`min-badge${item.quantity < minimum.minimum ? ' below' : ''}`}
onClick={onEditMinimum}
title={item.quantity < minimum.minimum ? 'Unter dem Mindestbestand' : 'Mindestbestand'}
>
min {String(minimum.minimum).replace('.', ',')}
</button>
{' · '}
</>
)}
✎ {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>
)}
</div>
<div className="stepper" role="group" aria-label={`Menge ${label}`}>
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { parseQty } from '../../units.js';
import ProductInput from './ProductInput.jsx';
import UnitSelect from './UnitSelect.jsx';
/**
* Mindestbestände der gewählten Liste: fällt der Vorrat darunter, kommt das Produkt automatisch auf die
* Einkaufsliste (bis zur fehlenden Menge). start: Produkt zum Vorbelegen ({ name, unit, quantity }) oder null.
*/
export default function MinimumDialog({ ref, minimums, products, start, onSave, busy }) {
const current = start && minimums.find((m) => m.name.toLocaleLowerCase('de-DE') === start.name.toLocaleLowerCase('de-DE') && m.unit === start.unit);
const [name, setName] = useState(start?.name || '');
const [unit, setUnit] = useState(start?.unit || 'stk');
const [qty, setQty] = useState(current ? String(current.minimum).replace('.', ',') : '');
const [invalid, setInvalid] = useState(false);
async function submit(e) {
e.preventDefault();
const q = parseQty(qty, unit);
setInvalid(q == null || !name.trim());
if (q == null || !name.trim()) return;
if (await onSave(name.trim(), unit, q)) { setName(''); setQty(''); }
}
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Mindestbestand</h2>
<p className="hint">Fällt der Vorrat unter diese Menge, kommt das Produkt automatisch auf die Einkaufsliste – genau so viel, wie fehlt.</p>
{minimums.length > 0 && (
<ul className="product-list">
{minimums.map((m) => (
<li key={`${m.key}-${m.unit}`}>
<span>{m.name} <small>immer {String(m.minimum).replace('.', ',')} {m.unit === 'stk' ? 'Stück' : m.unit}</small></span>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onSave(m.name, m.unit, 0)} aria-label={`Mindestbestand ${m.name} entfernen`}>
Entfernen
</button>
</li>
))}
</ul>
)}
<form className="household-add" onSubmit={submit} noValidate>
<label className="sr-only" htmlFor="min-name">Produkt</label>
<ProductInput id="min-name" value={name} onChange={setName} products={products} placeholder="Produkt, z. B. Milch" maxLength={60} required />
<div className="qty-unit">
<label className="sr-only" htmlFor="min-qty">Mindestmenge</label>
<input id="min-qty" className="household-qty-input" type="text" inputMode="decimal" value={qty} placeholder="2"
onChange={(e) => { setQty(e.target.value); setInvalid(false); }} aria-invalid={invalid || undefined} />
<UnitSelect id="min-unit" value={unit} onChange={setUnit} />
</div>
<button className="btn" type="submit" disabled={busy}>Speichern</button>
{invalid && <p className="error household-add-error">Produkt und Menge angeben (Anzahl: ganze Zahl, kg/l: z. B. 0,5).</p>}
</form>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}
@@ -1,18 +1,28 @@
// Vorschläge (Produktkatalog) aufräumen, z. B. Tippfehler; Einträge auf den Listen bleiben
export default function ProductsDialog({ ref, products, onDelete }) {
export default function ProductsDialog({ ref, products, categories = [], productCategories = {}, onCategory, onDelete }) {
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Vorschläge verwalten</h2>
<p className="hint">
Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten.
Kategorie für „Nach Laden“ festlegen, Tippfehler oder doppelte Schreibweisen löschen. Einträge auf den Listen bleiben erhalten.
</p>
<ul className="product-list">
{sorted.map((name) => (
<li key={name}>
<span>{name}</span>
{categories.length > 0 && (
<select
className="unit-select category-select"
value={productCategories[name] || 'sonstiges'}
onChange={(e) => onCategory(name, e.target.value)}
aria-label={`Kategorie von ${name}`}
>
{categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
)}
<button className="link-btn danger" type="button" onClick={() => onDelete(name)} aria-label={`Vorschlag ${name} löschen`}>
Löschen
</button>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../api.js';
import { ceil2, fmtItems, fmtQty, parseQty } from '../../units.js';
import ProductInput from './ProductInput.jsx';
@@ -23,36 +23,83 @@ function loadPeople() {
* Rezept abgleichen: Zutaten laden, auf Personenzahl umrechnen, mit dem Vorrat vergleichen
* und Fehlendes in den Einkauf der gewählten Liste setzen. find(list, name) sucht in Vorrat bzw. Einkauf dieser Liste.
*/
export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy }) {
/**
* stockKey: ändert sich, wenn sich der Vorrat ändert – dann werden die Rezeptvorschläge neu bewertet.
*/
export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy, stockKey }) {
const [mode, setMode] = useState('url'); // url | text
const [url, setUrl] = useState('');
const [text, setText] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [recipe, setRecipe] = useState(null);
const [saved, setSaved] = useState(null); // gemerkte Rezepte mit Bewertung nach Vorrat
async function load(e) {
e.preventDefault();
const listId = shoppingList?.id;
const loadSaved = useCallback(async () => {
if (!listId) return;
try {
setSaved(await api('GET', `/api/haushalt/rezepte?listId=${listId}`));
} catch {
setSaved([]);
}
}, [listId]);
useEffect(() => { loadSaved(); }, [loadSaved, stockKey]);
function show(data) {
// Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben
const people = loadPeople() || data.servings || 4;
setRecipe({
...data,
base: String(data.servings || people),
people: String(people),
rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })),
done: null,
});
}
async function request(method, path, body) {
setLoading(true);
setError(null);
try {
const data = await api('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text });
// Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben
const people = loadPeople() || data.servings || 4;
setRecipe({
...data,
base: String(data.servings || people),
people: String(people),
rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })),
done: null,
});
return await api(method, path, body);
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}
async function load(e) {
e.preventDefault();
const data = await request('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text });
if (data) show(data);
}
async function openSaved(id) {
const data = await request('GET', `/api/haushalt/rezepte/${id}`);
if (data) show(data);
}
async function remember() {
// Eingefügte Zutatenlisten haben keinen eigenen Titel – beim Merken nach einem Namen fragen
let title = recipe.title;
if (!recipe.source) {
title = window.prompt('Name für das Rezept:', title === 'Eigene Zutatenliste' ? '' : title)?.trim();
if (!title) return;
}
const res = await request('POST', '/api/haushalt/rezepte', {
title, source: recipe.source, servings: recipe.servings, lines: recipe.lines,
});
if (res) { setRecipe((r) => ({ ...r, id: res.id, title })); loadSaved(); }
}
async function forget(r) {
if (!window.confirm(`Rezept „${r.title}“ aus der Sammlung löschen?`)) return;
if (await request('DELETE', `/api/haushalt/rezepte/${r.id}`)) loadSaved();
}
if (!recipe) {
return (
<section className="card recipe" aria-labelledby="recipe-title">
@@ -86,6 +133,25 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver,
<button className="btn" type="submit" disabled={loading}>{loading ? 'Lade …' : 'Zutaten prüfen'}</button>
</form>
{error && <p className="error" role="alert">{error}</p>}
{saved?.length > 0 && (
<div className="recipe-saved">
<h3>Was kann ich kochen? <small>– gemerkte Rezepte, nach Vorrat sortiert</small></h3>
<ul>
{saved.map((r) => (
<li key={r.id} className={r.have === r.total ? 'complete' : undefined}>
<button type="button" className="recipe-saved-title" onClick={() => openSaved(r.id)} disabled={loading}>{r.title}</button>
<span className="recipe-score">{r.total ? `${r.have}/${r.total} da` : 'nur Grundzutaten'}</span>
<small className="recipe-missing">
{r.missing.length ? `fehlt: ${r.missing.slice(0, 4).join(', ')}${r.missing.length > 4 ? ` +${r.missing.length - 4}` : ''}` : 'alles da'}
</small>
{r.canDelete && (
<button className="link-btn danger" type="button" onClick={() => forget(r)} aria-label={`Rezept ${r.title} löschen`}>✕</button>
)}
</li>
))}
</ul>
</div>
)}
</section>
);
}
@@ -181,7 +247,12 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver,
? <a href={recipe.source} target="_blank" rel="noopener noreferrer">{recipe.title} ↗</a>
: recipe.title}
</h2>
<button className="link-btn" type="button" onClick={() => setRecipe(null)}>✕ Abgleich schließen</button>
<span className="recipe-head-actions">
{recipe.id
? <span className="hint">★ Gemerkt</span>
: <button className="link-btn" type="button" onClick={remember} disabled={loading}>☆ Merken</button>}
<button className="link-btn" type="button" onClick={() => { setRecipe(null); loadSaved(); }}>✕ Abgleich schließen</button>
</span>
</header>
<div className="recipe-people">
+71 -1
View File
@@ -1,9 +1,11 @@
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import CategoryOrderDialog from '../components/haushalt/CategoryOrderDialog.jsx';
import ListDialog from '../components/haushalt/ListDialog.jsx';
import ListPanel, { LISTS } from '../components/haushalt/ListPanel.jsx';
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 { isCalm } from '../motion.js';
@@ -13,11 +15,16 @@ import useLiveData from '../useLiveData.js';
const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LIST_KEY = 'p42.haushaltListe';
const SORT_KEY = 'p42.einkaufSortierung';
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
function loadSort() {
try { return localStorage.getItem(SORT_KEY) === 'laden' ? 'laden' : 'az'; } catch { return 'az'; }
}
function loadListId() {
try { return Number(localStorage.getItem(LIST_KEY)) || null; } catch { return null; }
}
@@ -29,6 +36,12 @@ export default function Haushalt() {
// Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten
const [listDialog, setListDialog] = useState(null);
const dialog = useRef(null);
const [sortMode, setSortMode] = useState(loadSort);
// Dialoge: Mindestbestand (mit vorbelegtem Produkt) und Laden-Reihenfolge; opened erzwingt frischen Inhalt
const [minDialog, setMinDialog] = useState(null);
const [orderDialog, setOrderDialog] = useState(null);
const minRef = useRef(null);
const orderRef = useRef(null);
const listDialogRef = useRef(null);
// Jede Änderung liefert den aktuellen Stand aller Listen zurück.
@@ -80,6 +93,23 @@ export default function Haushalt() {
return Boolean(await run('DELETE', `/api/haushalt/listen/${list.id}`));
}
function chooseSort(mode) {
setSortMode(mode);
try { localStorage.setItem(SORT_KEY, mode); } catch { /* ignorieren */ }
}
function openMinimums(item) {
setMinDialog({ start: item ? { name: item.name, unit: item.unit } : null, opened: Date.now() });
requestAnimationFrame(() => minRef.current?.showModal());
}
function openOrder() {
setOrderDialog({ opened: Date.now() });
requestAnimationFrame(() => orderRef.current?.showModal());
}
const setCategory = (name, category) => run('PUT', '/api/haushalt/produkte/kategorie', { name, category });
const items = data?.items || [];
const lists = data?.lists || [];
// Gewählte Liste (z. B. Zuhause, Urlaub); gibt es sie nicht (mehr), die erste sichtbare
@@ -139,6 +169,7 @@ export default function Haushalt() {
find={find}
busy={busy}
shoppingList={current}
stockKey={byList.vorrat.map((i) => `${i.id}:${i.quantity}`).join(',')}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })}
/>
@@ -169,6 +200,16 @@ export default function Haushalt() {
actions={actions}
busy={busy}
hidden={tab !== list}
extras={{
minimums: (data.minimums || []).filter((m) => m.listId === current.id),
categories: data.categories || [],
categoryOrder: current.categoryOrder || [],
sortMode,
onSortMode: chooseSort,
onEditOrder: openOrder,
onEditMinimums: openMinimums,
onCategory: setCategory,
}}
/>
))}
</div>
@@ -187,7 +228,36 @@ export default function Haushalt() {
/>
)}
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
<ProductsDialog
ref={dialog}
products={data?.products || []}
categories={data?.categories || []}
productCategories={data?.productCategories || {}}
onCategory={setCategory}
onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)}
/>
{minDialog && current && (
<MinimumDialog
key={minDialog.opened}
ref={minRef}
minimums={(data?.minimums || []).filter((m) => m.listId === current.id)}
products={data?.products || []}
start={minDialog.start}
busy={busy}
onSave={(name, unit, minimum) => run('PUT', `/api/haushalt/listen/${current.id}/mindestbestand`, { name, unit, minimum })}
/>
)}
{orderDialog && current && (
<CategoryOrderDialog
key={orderDialog.opened}
ref={orderRef}
categories={data?.categories || []}
order={current.categoryOrder || []}
busy={busy}
onSave={(order) => run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order })}
/>
)}
</>
);
}
+30
View File
@@ -280,3 +280,33 @@ html.vt-items .household-item { view-transition-name: var(--vt); }
.household-item, .recipe-row, .product-list li { border-top-style: solid; border-top-color: var(--border); }
.household-item:hover { background: var(--accent-soft); }
.segmented .count::before, .segmented .count::after { content: none; }
/* Mindestbestand, Laden-Sortierung */
.household-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin-top: -6px; }
.sort-toggle { display: inline-flex; border: 1px solid var(--line-strong); border-radius: var(--radius-s); overflow: hidden; }
.sort-toggle button { font: inherit; font-size: 0.78rem; padding: 4px 10px; border: 0; background: transparent; color: var(--text); cursor: pointer; }
.sort-toggle button[aria-pressed='true'] { background: var(--accent); color: var(--accent-ink); }
.household-group-head { margin: 12px 0 2px; font-size: var(--label-size); letter-spacing: var(--label-tracking); text-transform: uppercase; color: var(--accent); }
.household-group-head:first-child { margin-top: 0; }
.min-badge { font: inherit; font-size: inherit; padding: 0; border: 0; background: none; color: var(--muted); text-decoration: underline dotted; cursor: pointer; }
.min-badge.below { color: var(--warn); font-weight: 600; }
.category-select { margin-top: 2px; max-width: 14em; font-size: 0.72rem; min-height: 22px; padding: 0 2px; color: var(--muted); background: transparent; border-color: transparent; }
.category-select:hover, .category-select:focus { border-color: var(--border); color: var(--text); }
.order-list { margin: 0; padding-left: 1.6em; display: grid; gap: 4px; }
.order-list li { display: flex; align-items: center; gap: 8px; }
.order-list li span { flex: 1; }
.product-list .category-select { margin: 0 8px 0 auto; }
/* Rezeptsammlung */
.recipe-head-actions { display: inline-flex; gap: 14px; align-items: baseline; }
.recipe-saved { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.recipe-saved h3 { margin: 0 0 8px; font-size: 0.95rem; }
.recipe-saved h3 small { font-weight: 400; color: var(--muted); text-transform: none; letter-spacing: 0; }
.recipe-saved ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.recipe-saved li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 6px 0; border-top: 1px dashed var(--border); }
.recipe-saved li:first-child { border-top: 0; }
.recipe-saved-title { font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
.recipe-saved-title:hover { color: var(--accent); }
.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); }