Files
p42.f-w/frontend/src/components/haushalt/ListPanel.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

250 lines
9.0 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 { personName } from '../../api.js';
import { fmtItems, parseQty, round2, STEP } from '../../units.js';
import ProductInput from './ProductInput.jsx';
import UnitSelect from './UnitSelect.jsx';
// Die beiden Bereiche einer Liste: Vorrat (was da ist) und Einkaufen (was fehlt)
export const LISTS = {
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.' },
};
export default function ListPanel({ list, items, products, find, actions, busy, hidden }) {
const { title, lead, empty } = LISTS[list];
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
// Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat
const open = items.filter((i) => !i.checked);
const inCart = items.filter((i) => i.checked);
// 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">
{open.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{inCart.length > 0 && (
<li className="household-cart-head" aria-hidden="true">Im Wagen</li>
)}
{inCart.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
</ul>
)}
{inCart.length > 0 && (
<div className="household-finish">
<span className="hint">
{inCart.length} {inCart.length === 1 ? 'Produkt' : 'Produkte'} im Wagen
</span>
<button className="btn" type="button" disabled={busy} onClick={actions.finish}>
Einkauf beenden ({inCart.length})
</button>
</div>
)}
</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${item.checked ? ' checked' : ''}`} style={{ '--vt': `item-${item.id}` }}>
{isShopping && (
<input
type="checkbox"
className="household-check"
checked={item.checked}
disabled={busy}
onChange={() => actions.check(item, !item.checked)}
aria-label={item.checked ? `${label} im Wagen – Haken entfernen` : `${label} abhaken (im Wagen)`}
title={item.checked ? 'Im Wagen – zum Zurücknehmen klicken' : 'Abhaken: kommt mit „Einkauf beenden“ in den Vorrat'}
/>
)}
<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>
);
}