Listen mit eigenem Vorrat und Einkauf, Städte anderer auf der Reise-Karte, Design "Fabrik"

Vorrat & Einkauf:
- Mehrere Listen (z. B. "Zuhause", "Urlaub"), jede mit eigenem Vorrat und
  eigener Einkaufsliste; Auswahl oben auf der Seite, gemerkt pro Browser
- Beim Anlegen festlegen, wer die Liste sieht: alle Konten oder nur
  ausgewählte (Ersteller sieht sie immer); ändern/löschen nur Ersteller
  und Administratoren
- Gekauft, aufgebraucht, Nachkaufen und Rezeptabgleich wirken innerhalb
  der gewählten Liste
- Migration: bisheriger Vorrat und Einkauf werden zur Liste "Zuhause"
  (für alle sichtbar)

Reisen:
- Schalter "Städte anderer zeigen": Städte der übrigen Konten in je einer
  eigenen Farbe, einzelne Personen ein-/ausblendbar, nur lesend
- Info-Karte zeigt, wer noch in einer Stadt bzw. einem Land war

Design "Fabrik" (Industrie-Inventar à la Satisfactory):
- Stahlplatten mit Schrauben, Titelleisten mit Fenstersymbol, gelb
  leuchtende Hauptbuttons, orange Mengen-Zähler, Warnstreifen,
  Riffelblech und drehende Zahnräder im Hintergrund
- Schriften Roboto / Roboto Condensed (OFL), lokal eingebunden

Doku: README verlinkt das zugehörige Repository P42-S.M.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 11:50:46 +02:00
1 parent f16c8c73c6
commit b30acb652a
20 files changed
+1182 -82

No files matched your search

+222 -9
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, personName } from '../api.js';
import { useAuth } from '../auth.jsx';
import ProductInput from '../components/ProductInput.jsx';
import RecipeCheck from '../components/RecipeCheck.jsx';
import UnitSelect from '../components/UnitSelect.jsx';
@@ -8,9 +9,10 @@ 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 LIST_KEY = 'p42.haushaltListe';
const LISTS = {
vorrat: { title: 'Vorrat', lead: 'Was zu Hause ist', empty: 'Noch nichts im Vorrat eingetragen.' },
vorrat: { title: 'Vorrat', lead: 'Was da ist', empty: 'Noch nichts im Vorrat eingetragen.' },
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
@@ -18,12 +20,20 @@ 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] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
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);
const load = useCallback(async () => {
try {
@@ -44,13 +54,14 @@ export default function Haushalt() {
};
}, [load]);
// Jede Änderung liefert den aktuellen Stand beider Listen zurück
// Jede Änderung liefert den aktuellen Stand aller Listen zurück
async function run(method, url, body) {
setBusy(true);
setError(null);
try {
setData(await api(method, url, body));
return true;
const next = await api(method, url, body);
setData(next);
return next;
} catch (e) {
setError(e.message);
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
@@ -66,16 +77,50 @@ export default function Haushalt() {
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: items.filter((i) => i.list === 'vorrat'),
einkauf: items.filter((i) => i.list === 'einkauf'),
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 }),
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`),
remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`),
@@ -86,7 +131,7 @@ export default function Haushalt() {
<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>
<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>
@@ -97,12 +142,29 @@ export default function Haushalt() {
{!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}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', body)}
shoppingList={current}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })}
/>
<div className="segmented" role="tablist" aria-label="Liste wählen">
@@ -138,6 +200,18 @@ export default function Haushalt() {
</>
)}
{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)}`)} />
</>
);
@@ -361,6 +435,145 @@ function ItemRow({ item, list, other, actions, busy }) {
);
}
// ---------- Listen (je Vorrat + Einkauf): wählen, anlegen, freigeben ----------
function visibleFor(list, users) {
if (list.everyone) return 'alle Konten';
const ids = new Set([list.createdBy?.id, ...list.members]);
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
return names.length ? names.join(', ') : 'niemanden';
}
function ListSwitcher({ lists, current, users, onChoose, onNew, onEdit, busy }) {
return (
<div className="shopping-lists">
<ul className="chip-list" aria-label="Liste wählen">
<li className="shopping-lists-label">Liste:</li>
{lists.map((l) => (
<li key={l.id}>
<button
type="button"
className={`chip${l.id === current?.id ? ' active' : ''}`}
aria-pressed={l.id === current?.id}
onClick={() => onChoose(l.id)}
title={`Sichtbar für ${visibleFor(l, users)}`}
>
{!l.everyone && <span aria-label="nicht für alle sichtbar">🔒 </span>}
{l.name}
</button>
</li>
))}
<li>
<button type="button" className="link-btn" onClick={onNew} disabled={busy}>+ Neue Liste</button>
</li>
</ul>
{current && (
<p className="hint shopping-visibility">
Sichtbar für: {visibleFor(current, users)}
{current.createdBy && ` · angelegt von ${current.createdBy.name}`}
{current.canManage && (
<>
{' · '}
<button type="button" className="link-btn" onClick={() => onEdit(current)} disabled={busy}>✎ Liste bearbeiten</button>
</>
)}
</p>
)}
</div>
);
}
function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) {
const { user } = useAuth();
const ownerId = list ? list.createdBy?.id : user?.id;
const [form, setForm] = useState(() => ({
name: list?.name || '',
everyone: list ? list.everyone : true,
members: list?.members || [],
}));
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl
const choosable = users.filter((u) => u.id !== ownerId);
const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members });
setBusy(false);
if (err) setError(err);
else ref.current.close();
}
async function remove() {
setBusy(true);
const done = await onDelete(list);
setBusy(false);
if (done) ref.current.close();
}
return (
<dialog className="modal task-dialog" ref={ref} onClose={onClosed}>
<form className="form" onSubmit={submit}>
<h2>{list ? 'Liste bearbeiten' : 'Neue Liste'}</h2>
{!list && <p className="hint">Die Liste bekommt einen eigenen Vorrat und eine eigene Einkaufsliste – z. B. für die Ferienwohnung im Urlaub.</p>}
<label>
Name
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" />
</label>
<fieldset className="link-picker">
<legend>Wer kann die Liste sehen?</legend>
<ul>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} />
Alle Konten
</label>
</li>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} />
Nur ausgewählte Konten
</label>
</li>
</ul>
{!form.everyone && (
<>
<p className="hint">
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich:
</p>
{choosable.length === 0 ? (
<p className="hint">Es gibt keine weiteren Konten.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</>
)}
</fieldset>
{error && <p className="error">{error}</p>}
<div className="form-actions">
{list && (
<button className="btn ghost danger-btn list-delete" type="button" disabled={busy} onClick={remove}>Liste löschen</button>
)}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>{list ? 'Speichern' : 'Anlegen'}</button>
</div>
</form>
</dialog>
);
}
function ProductsDialog({ ref, products, onDelete }) {
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
return (