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>
390 lines
13 KiB
React
390 lines
13 KiB
React
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 & 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>
|
||
);
|
||
}
|