Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
131
web/src/pages/Shops.jsx
Normal file
131
web/src/pages/Shops.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
/**
|
||||
* Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ an Gegenständen.
|
||||
* Am Artikel ist die Auswahl optional (unbekannt / mehrere Herkünfte = leer).
|
||||
*/
|
||||
export default function Shops() {
|
||||
const confirm = useConfirm();
|
||||
const { isAdmin } = useAuth();
|
||||
const [shops, setShops] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", website: "" });
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setShops(await api.listShops());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createShop({ name: form.name, website: form.website || null });
|
||||
setForm({ name: "", website: "" });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function patch(shop, body) {
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateShop(shop.id, body);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(shop) {
|
||||
const hinweis = shop.product_count
|
||||
? ` ${shop.product_count} Artikel verlieren die Bezugsquelle, bleiben aber erhalten.`
|
||||
: "";
|
||||
const ok = await confirm({
|
||||
title: `Shop „${shop.name}“ löschen?`,
|
||||
message: `Die Liste ist nur eine Auswahlhilfe.${hinweis}`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteShop(shop.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Shops</h1>
|
||||
<div className="sub">
|
||||
Bezugsquellen für „gekauft bei“ an Gegenständen – einmal anlegen, überall auswählbar.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="card form-narrow">
|
||||
{isAdmin && (
|
||||
<form onSubmit={add}>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Name
|
||||
<input placeholder="z.B. MediaMarkt" value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</label>
|
||||
<label className="grow">
|
||||
Website (optional)
|
||||
<input placeholder="https://…" value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<ul className="simple-list">
|
||||
{shops.map((s) => (
|
||||
<li key={s.id}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8, flex: 1 }}>
|
||||
<Icon name="cart" size={15} className="muted" />
|
||||
{isAdmin ? (
|
||||
<input defaultValue={s.name} style={{ marginTop: 0, minWidth: 140 }}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== s.name) patch(s, { name: v });
|
||||
}} />
|
||||
) : <span className="strong">{s.name}</span>}
|
||||
{s.website && (
|
||||
<a href={s.website} target="_blank" rel="noreferrer" className="muted small">
|
||||
{s.website.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
)}
|
||||
{s.product_count > 0 && <span className="badge nowrap">{s.product_count} Artikel</span>}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(s)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{shops.length === 0 && <li className="empty">Noch keine Shops.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user