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>
This commit is contained in:
1 parent
4aba48ce51
commit
fa58708e2d
41 files changed
+1264
-1241
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user