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>
This commit is contained in:
1 parent
4aba48ce51
commit
fa58708e2d
41 files changed
+1264
-1241
No files matched your search
@@ -0,0 +1,278 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user