- 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>
194 lines
7.3 KiB
React
194 lines
7.3 KiB
React
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 & 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)}`)} />
|
||
</>
|
||
);
|
||
}
|