Files
p42.f-w/frontend/src/pages/Haushalt.jsx
T
guyg1474andClaude Opus 5.5 dd58b3d1f8 P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

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

390 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, personName } from '../api.js';
import ProductInput from '../components/ProductInput.jsx';
import RecipeCheck from '../components/RecipeCheck.jsx';
import UnitSelect from '../components/UnitSelect.jsx';
import { fmtItems, parseQty, round2, STEP } from '../units.js';
// Geteilte Liste: regelmäßig nachladen, damit Änderungen der anderen ankommen
const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LISTS = {
vorrat: { title: 'Vorrat', lead: 'Was zu Hause ist', empty: 'Noch nichts im Vorrat eingetragen.' },
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
export default function Haushalt() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState(loadTab);
const dialog = useRef(null);
const load = useCallback(async () => {
try {
setData(await api('GET', '/api/haushalt'));
} catch (e) {
setError(e.message);
}
}, []);
useEffect(() => {
load();
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
const onVisible = () => !document.hidden && load();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [load]);
// Jede Änderung liefert den aktuellen Stand beider Listen zurück
async function run(method, url, body) {
setBusy(true);
setError(null);
try {
setData(await api(method, url, body));
return true;
} catch (e) {
setError(e.message);
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
load();
return false;
} finally {
setBusy(false);
}
}
function chooseTab(next) {
setTab(next);
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
}
const items = data?.items || [];
const byList = {
vorrat: items.filter((i) => i.list === 'vorrat'),
einkauf: items.filter((i) => i.list === 'einkauf'),
};
// 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 actions = {
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit }),
update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch),
move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`),
remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`),
};
return (
<>
<div className="page-head">
<div>
<h1>Vorrat &amp; Einkauf</h1>
<p className="lead">Gemeinsame Liste für alle Konten. Wird alle {REFRESH_SECONDS} Sekunden aktualisiert.</p>
</div>
{data?.products.length > 0 && (
<button className="link-btn" type="button" onClick={() => dialog.current.showModal()}>✎ Vorschläge verwalten</button>
)}
</div>
{error && <p className="error household-error" role="alert">{error}</p>}
{!data && !error && <p className="hint">Lade …</p>}
{data && (
<>
<RecipeCheck
products={data.products}
find={find}
busy={busy}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', body)}
/>
<div className="segmented" role="tablist" aria-label="Liste wählen">
{Object.entries(LISTS).map(([key, { title }]) => (
<button
key={key}
type="button"
role="tab"
id={`tab-${key}`}
aria-selected={tab === key}
aria-controls={`panel-${key}`}
onClick={() => chooseTab(key)}
>
{title} <span className="count">{byList[key].length}</span>
</button>
))}
</div>
<div className="household">
{Object.keys(LISTS).map((list) => (
<ListPanel
key={list}
list={list}
items={byList[list]}
products={data.products}
find={find}
actions={actions}
busy={busy}
hidden={tab !== list}
/>
))}
</div>
</>
)}
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
</>
);
}
function ListPanel({ list, items, products, find, actions, busy, hidden }) {
const { title, lead, empty } = LISTS[list];
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
function hint(name) {
const here = find(list, name);
if (here.length) return `schon da: ${fmtItems(here)}`;
const there = find(other, name);
if (there.length) return `${list === 'vorrat' ? 'auf Einkaufsliste' : 'im Vorrat'}: ${fmtItems(there)}`;
return null;
}
return (
<section
className={`card household-panel ${list}`}
id={`panel-${list}`}
role="tabpanel"
aria-labelledby={`title-${list}`}
data-hidden={hidden || undefined}
>
<header className="household-head">
<h2 id={`title-${list}`}>{title}</h2>
<span className="hint">{lead}</span>
</header>
<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">
{items.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
</ul>
)}
</section>
);
}
function AddForm({ list, products, hint, onAdd, busy }) {
const [name, setName] = useState('');
const [quantity, setQuantity] = useState('1');
const [unit, setUnit] = useState('stk');
const [invalid, setInvalid] = useState(false);
const nameInput = useRef(null);
async function submit(e) {
e.preventDefault();
const n = name.trim();
const q = parseQty(quantity, unit);
setInvalid(q == null);
if (!n || q == null) return;
if (await onAdd(list, n, q, unit)) {
setName('');
setQuantity('1');
nameInput.current?.focus();
}
}
return (
<form className="household-add" onSubmit={submit} noValidate>
<label className="sr-only" htmlFor={`add-${list}`}>Produkt</label>
<ProductInput
id={`add-${list}`}
inputRef={nameInput}
value={name}
onChange={setName}
products={products}
hint={hint}
placeholder={list === 'vorrat' ? 'Was ist da? z. B. Milch' : 'Was fehlt? z. B. Brot'}
maxLength={60}
required
/>
<div className="qty-unit">
<label className="sr-only" htmlFor={`qty-${list}`}>Menge</label>
<input
id={`qty-${list}`}
className="household-qty-input"
type="text"
inputMode="decimal"
value={quantity}
onChange={(e) => { setQuantity(e.target.value); setInvalid(false); }}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `qty-error-${list}` : undefined}
/>
<UnitSelect id={`unit-${list}`} value={unit} onChange={setUnit} />
</div>
<button className="btn" type="submit" disabled={busy}>Hinzufügen</button>
{invalid && (
<p className="error household-add-error" id={`qty-error-${list}`}>
{unit === 'stk' ? 'Anzahl als ganze Zahl von 1 bis 999 eingeben.' : 'Menge von 0,01 bis 999 eingeben, z. B. 1,5.'}
</p>
)}
</form>
);
}
// Menge direkt eintippen; gespeichert wird beim Verlassen des Felds oder mit Enter
function QuantityInput({ item, label, onCommit, disabled }) {
const shown = String(item.quantity).replace('.', ',');
const [draft, setDraft] = useState(null);
function commit() {
if (draft == null) return;
const q = parseQty(draft, item.unit);
setDraft(null);
if (q != null && q !== item.quantity) onCommit(q);
}
return (
<input
className="stepper-value"
type="text"
inputMode={item.unit === 'stk' ? 'numeric' : 'decimal'}
value={draft ?? shown}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setDraft(null);
}}
aria-label={`Menge ${label}`}
/>
);
}
function ItemRow({ item, list, other, actions, busy }) {
const isShopping = list === 'einkauf';
const step = STEP[item.unit];
const last = item.quantity - step <= 0;
// Bei gleichem Produkt in mehreren Einheiten die Einheit mit ansagen
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
return (
<li className="household-item">
{isShopping && (
<input
type="checkbox"
className="household-check"
checked={false}
disabled={busy}
onChange={() => actions.move(item)}
aria-label={`${label} gekauft – in den Vorrat übernehmen`}
title="Gekauft: in den Vorrat übernehmen"
/>
)}
<div className="household-name">
<strong>{item.name}</strong>
<small>
{other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `}
✎ {personName(item.updatedBy)}
</small>
</div>
<div className="stepper" role="group" aria-label={`Menge ${label}`}>
<button
type="button"
disabled={busy || (isShopping && last)}
onClick={() => (last ? actions.move(item) : actions.update(item, { quantity: round2(item.quantity - step) }))}
aria-label={last && !isShopping ? `${label} aufgebraucht – auf die Einkaufsliste` : `${label} weniger`}
title={last && !isShopping ? 'Aufgebraucht: auf die Einkaufsliste' : 'Weniger'}
>
−
</button>
<QuantityInput key={`${item.quantity}-${item.unit}`} item={item} label={label} disabled={busy} onCommit={(quantity) => actions.update(item, { quantity })} />
<button
type="button"
disabled={busy || item.quantity + step > 999}
onClick={() => actions.update(item, { quantity: round2(item.quantity + step) })}
aria-label={`${label} mehr`}
title="Mehr"
>
+
</button>
</div>
<UnitSelect
value={item.unit}
onChange={(unit) => actions.update(item, { unit })}
disabled={busy}
compact
label={`Einheit ${item.name}`}
/>
<div className="household-actions">
{!isShopping && (
<button
className="link-btn"
type="button"
disabled={busy || other.length > 0}
onClick={() => actions.add('einkauf', item.name, 1, item.unit)}
aria-label={other.length ? `${label} steht schon auf der Einkaufsliste` : `${label} nachkaufen`}
title={other.length ? 'Steht schon auf der Einkaufsliste' : 'Auf die Einkaufsliste setzen, Vorrat bleibt'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2" /><circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" />
</svg>
<span className="label">Nachkaufen</span>
</button>
)}
<button
className="link-btn danger"
type="button"
disabled={busy}
onClick={() => actions.remove(item)}
aria-label={`${label} entfernen`}
title="Entfernen"
>
✕
</button>
</div>
</li>
);
}
function ProductsDialog({ ref, products, 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.
</p>
<ul className="product-list">
{sorted.map((name) => (
<li key={name}>
<span>{name}</span>
<button className="link-btn danger" type="button" onClick={() => onDelete(name)} aria-label={`Vorschlag ${name} löschen`}>
Löschen
</button>
</li>
))}
</ul>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}