Files
p42.f-w/frontend/src/pages/Haushalt.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

194 lines
7.3 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 { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import ListDialog from '../components/haushalt/ListDialog.jsx';
import ListPanel, { LISTS } from '../components/haushalt/ListPanel.jsx';
import ListSwitcher from '../components/haushalt/ListSwitcher.jsx';
import ProductsDialog from '../components/haushalt/ProductsDialog.jsx';
import RecipeCheck from '../components/haushalt/RecipeCheck.jsx';
import { isCalm } from '../motion.js';
import useLiveData from '../useLiveData.js';
// Geteilte Listen: regelmäßig nachladen, damit Änderungen der anderen ankommen
const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LIST_KEY = 'p42.haushaltListe';
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
function loadListId() {
try { return Number(localStorage.getItem(LIST_KEY)) || null; } catch { return null; }
}
export default function Haushalt() {
const { data, setData, error, busy, run: send } = useLiveData('/api/haushalt', { refresh: REFRESH_SECONDS });
const [tab, setTab] = useState(loadTab);
const [listId, setListId] = useState(loadListId);
// Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten
const [listDialog, setListDialog] = useState(null);
const dialog = useRef(null);
const listDialogRef = useRef(null);
// Jede Änderung liefert den aktuellen Stand aller Listen zurück.
// animate: Einträge gleiten an ihren neuen Platz (z. B. abgehakt → nach unten), wo der Browser es kann
function run(method, url, body, { animate = false } = {}) {
if (!animate || !document.startViewTransition || isCalm()) return send(method, url, body);
return send(method, url, body, {
apply: (next) => {
const root = document.documentElement;
root.classList.add('vt-items');
const vt = document.startViewTransition(() => flushSync(() => setData(next)));
vt.finished.finally(() => root.classList.remove('vt-items'));
},
});
}
function chooseTab(next) {
setTab(next);
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
}
function chooseList(id) {
setListId(id);
try { localStorage.setItem(LIST_KEY, String(id)); } catch { /* ignorieren */ }
}
function openListDialog(list) {
setListDialog({ list });
// Erst nach dem Rendern öffnen, damit der Dialog mit den neuen Werten startet
requestAnimationFrame(() => listDialogRef.current?.showModal());
}
async function saveList(form) {
const editing = listDialog?.list;
try {
const next = await api(editing ? 'PATCH' : 'POST', editing ? `/api/haushalt/listen/${editing.id}` : '/api/haushalt/listen', form);
setData(next);
if (next.created) chooseList(next.created);
return null;
} catch (e) {
return e.message;
}
}
async function deleteList(list) {
const count = (data?.items || []).filter((i) => i.listId === list.id).length;
const what = count ? ` mit ${count} ${count === 1 ? 'Eintrag' : 'Einträgen'}` : '';
if (!window.confirm(`Liste „${list.name}“ samt Vorrat und Einkauf${what} löschen?`)) return false;
return Boolean(await run('DELETE', `/api/haushalt/listen/${list.id}`));
}
const items = data?.items || [];
const lists = data?.lists || [];
// Gewählte Liste (z. B. Zuhause, Urlaub); gibt es sie nicht (mehr), die erste sichtbare
const current = lists.find((l) => l.id === listId) || lists[0] || null;
const mine = current ? items.filter((i) => i.listId === current.id) : [];
const byList = {
vorrat: mine.filter((i) => i.list === 'vorrat'),
einkauf: mine.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, listId: current?.id }),
update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch),
move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`),
check: (item, checked) => run('PATCH', `/api/haushalt/items/${item.id}`, { checked }, { animate: true }),
finish: () => run('POST', `/api/haushalt/listen/${current.id}/einkauf-beenden`, undefined, { animate: true }),
remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`),
};
return (
<>
<div className="page-head">
<div>
<h1>Vorrat &amp; Einkauf</h1>
<p className="lead">Jede Liste hat ihren eigenen Vorrat und Einkauf – z. B. Zuhause und Urlaub. 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 && (
<ListSwitcher
lists={lists}
current={current}
users={data.users}
onChoose={chooseList}
onNew={() => openListDialog(null)}
onEdit={openListDialog}
busy={busy}
/>
)}
{data && !current && (
<p className="hint household-empty">Keine Liste, die du sehen kannst. Leg mit „+ Neue Liste“ eine an.</p>
)}
{data && current && (
<>
<RecipeCheck
products={data.products}
find={find}
busy={busy}
shoppingList={current}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })}
/>
<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>
</>
)}
{listDialog && (
<ListDialog
key={listDialog.list?.id ?? 'neu'}
ref={listDialogRef}
list={listDialog.list}
users={data?.users || []}
onSave={saveList}
onDelete={deleteList}
onClosed={() => setListDialog(null)}
/>
)}
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
</>
);
}