Files
p42.f-w/frontend/src/components/haushalt/RecipeCheck.jsx
T
guyg1474andClaude Opus 5.5 fa58708e2d Frontend: gemeinsamer Hook useLiveData, Komponenten nach Bereichen, große Seiten aufgeteilt
- useLiveData: Laden, Nachladen (sichtbarer Tab) und Speichern mit
  Fehlerbehandlung an einer Stelle; genutzt von Vorrat, Reisen,
  Kanban-Übersicht und Kanban-Board (Monitoring/Benutzer arbeiten anders)
- components/<bereich>/: haushalt, reisen, kanban, monitoring, projekte,
  start, design (per git mv, Historie bleibt)
- Reisen.jsx 783 → 227 Zeilen (CitySearch, VisitedList, InfoCard,
  ExportDialog, laender.js), Haushalt.jsx 634 → 193 (ListPanel,
  ListSwitcher, ListDialog, ProductsDialog), KanbanBoard.jsx 452 → 277
  (board.js, TaskCard, TaskDialog)
- storage.js für Einstellungen pro Browser; Städteliste in Reisen memoisiert
- Verhalten unverändert (Browser-Durchlauf aller Seiten und Abläufe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:14:32 +02:00

279 lines
11 KiB
React

import { useState } from 'react';
import { api } from '../../api.js';
import { ceil2, fmtItems, fmtQty, parseQty } from '../../units.js';
import ProductInput from './ProductInput.jsx';
import UnitSelect from './UnitSelect.jsx';
const PEOPLE_KEY = 'p42.rezeptPersonen';
const UNIT_LABELS = { el: 'EL', tl: 'TL', pck: 'Pck.', msp: 'Msp.', stk: 'Stk.', liter: 'l', gr: 'g' };
const unitLabel = (u) => UNIT_LABELS[u] || (u.length > 2 ? u[0].toUpperCase() + u.slice(1) : u);
// Rezeptmengen, die sich in kg bzw. l umrechnen lassen
const CONVERT = {
g: ['kg', 0.001], gr: ['kg', 0.001], gramm: ['kg', 0.001], mg: ['kg', 0.000001], kg: ['kg', 1],
ml: ['l', 0.001], cl: ['l', 0.01], dl: ['l', 0.1], l: ['l', 1], liter: ['l', 1],
};
const fmt = (n) => (n >= 10 ? Math.round(n) : Math.round(n * 10) / 10).toLocaleString('de-DE');
function loadPeople() {
try { return Number(localStorage.getItem(PEOPLE_KEY)) || null; } catch { return null; }
}
/**
* 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 }) {
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);
async function load(e) {
e.preventDefault();
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,
});
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
if (!recipe) {
return (
<section className="card recipe" aria-labelledby="recipe-title">
<header className="household-head">
<h2 id="recipe-title">Rezept abgleichen</h2>
<button className="link-btn" type="button" onClick={() => { setMode(mode === 'url' ? 'text' : 'url'); setError(null); }}>
{mode === 'url' ? 'Zutaten selbst einfügen' : 'Rezept-Link verwenden'}
</button>
</header>
<form className="recipe-load" onSubmit={load}>
{mode === 'url' ? (
<input
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="Rezept-Link einfügen, z. B. von chefkoch.de"
aria-label="Rezept-Link"
required
/>
) : (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
placeholder={'Eine Zutat pro Zeile, z. B.\n300 g Mehl\n2 Eier'}
aria-label="Zutatenliste"
required
/>
)}
<button className="btn" type="submit" disabled={loading}>{loading ? 'Lade …' : 'Zutaten prüfen'}</button>
</form>
{error && <p className="error" role="alert">{error}</p>}
</section>
);
}
const base = Number(recipe.base) || 0;
const people = Number(recipe.people) || 0;
const factor = base > 0 && people > 0 ? people / base : 1;
// Status je Zutat aus aktuellem Vorrat und gewählter Personenzahl
const rows = recipe.rows.map((row) => {
const scaled = row.amount != null ? row.amount * factor : null;
const stock = find('vorrat', row.product);
const listed = find('einkauf', row.product);
const convert = row.unit && CONVERT[row.unit];
// Vergleichbar sind Gramm/ml mit kg/l im Vorrat und Stückzahlen mit "Anzahl"
const measure = scaled == null ? null
: convert ? { unit: convert[0], need: scaled * convert[1] }
: !row.unit ? { unit: 'stk', need: Math.ceil(scaled - 1e-9) }
: null;
const same = measure && stock.find((s) => s.unit === measure.unit);
let status;
let missing;
if (same) {
missing = Math.max(0, measure.need - same.quantity);
status = missing <= 1e-9 ? 'ok' : 'part';
} else if (stock.length) {
// Vorhanden, aber in anderer Einheit (z. B. 1 Packung Mehl): nur "vorhanden" prüfbar
missing = 0;
status = 'ok';
} else {
missing = measure ? measure.need : 1;
status = 'missing';
}
const buyUnit = measure?.unit || 'stk';
// Vorhandenes trotzdem ankreuzen: dann die volle benötigte Menge vorschlagen
const amount = missing > 1e-9 ? missing : measure ? measure.need : 1;
const autoQty = buyUnit === 'stk' ? Math.max(1, Math.ceil(amount)) : ceil2(amount);
const autoTake = status !== 'ok' && !listed.length && !row.optional && !row.staple;
return {
...row,
scaled,
stock,
listed,
status,
missing,
unitBuy: row.unitBuy ?? buyUnit,
take: row.take ?? autoTake,
qty: row.qty ?? String(autoQty).replace('.', ','),
};
});
const chosen = rows.filter((r) => r.take && r.product.trim());
const update = (index, patch) =>
setRecipe((r) => ({ ...r, done: null, invalid: null, rows: r.rows.map((row, i) => (i === index ? { ...row, ...patch } : row)) }));
function setPeople(value) {
setRecipe((r) => ({ ...r, people: value, done: null, rows: r.rows.map((row) => ({ ...row, qty: null })) }));
try { if (Number(value) > 0) localStorage.setItem(PEOPLE_KEY, value); } catch { /* ignorieren */ }
}
async function takeOver() {
const items = chosen.map((r) => ({ name: r.product.trim(), quantity: parseQty(r.qty, r.unitBuy), unit: r.unitBuy }));
const invalid = chosen.find((r, i) => items[i].quantity == null);
if (invalid) {
setRecipe((r) => ({ ...r, invalid: invalid.raw }));
return;
}
// Von Hand geänderte Zuordnungen merken, damit sie beim nächsten Rezept passen
const aliases = rows
.filter((r) => r.product.trim() && r.product.trim() !== r.matched)
.map((r) => ({ ingredient: r.name, product: r.product.trim() }));
if (await onTakeOver({ items, aliases })) {
setRecipe((r) => ({
...r,
done: items.length,
invalid: null,
rows: r.rows.map((row) => ({ ...row, matched: row.product.trim() || row.matched, take: null, qty: null, unitBuy: null })),
}));
}
}
const STATUS = {
ok: (r) => [`Vorhanden${r.stock.length ? ` (${fmtItems(r.stock)})` : ''}`, 'ok'],
part: (r) => [`Fehlen ${fmtQty(r.unitBuy === 'stk' ? Math.ceil(r.missing) : ceil2(r.missing), r.unitBuy)}`, 'warn'],
missing: () => ['Fehlt', 'bad'],
};
return (
<section className="card recipe" aria-labelledby="recipe-title">
<header className="household-head">
<h2 id="recipe-title">
{recipe.source
? <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>
</header>
<div className="recipe-people">
<label>
Rezept ist für
<input type="number" inputMode="numeric" min={1} max={100} value={recipe.base}
onChange={(e) => setRecipe((r) => ({ ...r, base: e.target.value, done: null, rows: r.rows.map((row) => ({ ...row, qty: null })) }))} />
Portionen
</label>
<label className="recipe-people-main">
Ich koche für
<input type="number" inputMode="numeric" min={1} max={100} value={recipe.people} onChange={(e) => setPeople(e.target.value)} />
Personen
</label>
{!recipe.servings && <span className="hint">Das Rezept nennt keine Portionen, bitte prüfen.</span>}
</div>
<ul className="recipe-rows">
{rows.map((row, i) => {
// Fehlt, steht aber schon auf der Einkaufsliste: nicht rot warnen
const [label, tone] = row.status !== 'ok' && row.listed.length
? [`Auf Liste (${fmtItems(row.listed)})`, '']
: STATUS[row.status](row);
return (
<li key={row.raw} className={`recipe-row${row.take ? ' chosen' : ''}`}>
<input
type="checkbox"
className="household-check recipe-take"
checked={row.take}
onChange={(e) => update(i, { take: e.target.checked })}
aria-label={`${row.product} auf die Einkaufsliste`}
/>
<div className="recipe-ingredient">
<strong>
{row.scaled != null && `${fmt(row.scaled)} `}
{row.unit && `${unitLabel(row.unit)} `}
{row.name}
</strong>
<small>
{row.raw}
{row.optional && ' · optional'}
{row.listed.length > 0 && ` · steht auf Einkaufsliste (${fmtItems(row.listed)})`}
</small>
</div>
<div className="recipe-product">
<ProductInput
value={row.product}
onChange={(v) => update(i, { product: v, take: null, qty: null, unitBuy: null })}
products={products}
aria-label={`Produkt für ${row.name}`}
maxLength={60}
/>
</div>
<span className={`badge ${tone} recipe-status`}>{label}</span>
<div className="recipe-buy qty-unit">
<input
className="recipe-qty"
type="text"
inputMode="decimal"
value={row.qty}
disabled={!row.take}
onChange={(e) => update(i, { qty: e.target.value })}
aria-invalid={(row.take && parseQty(row.qty, row.unitBuy) == null) || undefined}
aria-label={`Menge ${row.product} zum Einkaufen`}
title="Menge für die Einkaufsliste"
/>
<UnitSelect
value={row.unitBuy}
disabled={!row.take}
onChange={(unitBuy) => update(i, { unitBuy })}
label={`Einheit ${row.product} zum Einkaufen`}
compact
/>
</div>
</li>
);
})}
</ul>
<footer className="recipe-foot">
<p className={recipe.invalid ? 'error' : 'hint'} aria-live="polite">
{recipe.invalid
? `Bitte die Menge bei „${recipe.invalid}“ prüfen (Anzahl: ganze Zahl, kg/l: z. B. 0,5).`
: recipe.done != null
? `✓ ${recipe.done} ${recipe.done === 1 ? 'Zutat' : 'Zutaten'} auf die Einkaufsliste „${shoppingList?.name}“ gesetzt.`
: 'Gramm und ml werden mit kg/l im Vorrat verglichen. EL, Packungen usw. nur auf „vorhanden“.'}
</p>
<button className="btn" type="button" disabled={busy || chosen.length === 0 || !shoppingList} onClick={takeOver}>
Fehlendes auf Einkaufsliste ({chosen.length})
</button>
</footer>
</section>
);
}