Gruppen und Kategorien getrennt, EAN-Codes an der Gruppe automatisch gefuehrt
Die Umbenennung von Gruppen in "Kategorien" war falsch: Es sind zwei
verschiedene Dinge. Sie ist zurueckgenommen, Kategorien kommen als eigene Ebene
dazu.
GRUPPE zaehlt Bestaende mehrerer Marken zusammen - Mehl von Rewe, Aldi und
Migros ergeben "5 kg Mehl". Dafuer Mindestbestand mit Einheit und EAN-Codes.
KATEGORIE ordnet allein die Artikelliste ("zeig mir alle Suesswaren"), ist
verschachtelbar wie ein Lagerort und hat weder Bestand noch EAN-Codes. Ein
Artikel kann beides, eines oder keines haben.
Die Vermischung war aelter als die Umbenennung: guessGroup in offUtils.js hat
aus der Open-Food-Facts-KATEGORIE eine GRUPPE geraten. Das ist entfernt. Die
OFF-Einordnung steuert jetzt die Kategorie, wo sie hingehoert; eine Gruppe
entsteht nur ueber einen hinterlegten Gruppen-Code oder bewusste Auswahl.
EAN-Codes an der Gruppe: Das war kein Anzeigefehler. Beim Anlegen eines Artikels
wurde ausschliesslich group_id gesetzt - ein Gruppen-Code entstand nie, die
Liste war tatsaechlich leer. Die Meldung "bereits vergeben" kam daher, dass der
Code am Artikel hing. Jetzt pflegt services/group_codes.py den Code mit: beim
Zuordnen kommt er hinzu, beim Gruppenwechsel wandert er mit, beim Entfernen der
Gruppe oder Loeschen des Artikels verschwindet er. Beim Scannen aendert sich
nichts an der Reihenfolge - der Artikel wird weiterhin zuerst gefunden; der
Gruppen-Eintrag ist Beleg in der Verwaltung und Rueckfall. Traegt man denselben
Code von Hand nach, ist das kein Fehler mehr, sondern die Auskunft, dass er ueber
den Artikel bereits dort steht.
Kategorien im Backend: neue Tabelle mit parent_id (Muster von Location),
products.category_id per ADD COLUMN IF NOT EXISTS nachgezogen, deutsche
zweistufige Startliste analog zu den eingebauten Einheiten. Die Startliste wird
nur angelegt, wenn ueberhaupt noch keine Kategorie existiert - wer sie bewusst
leerraeumt, findet sie nicht wieder. Beim Setzen einer Oberkategorie wird
geprueft, dass keine Kategorie sich selbst oder einem eigenen Nachfahren
untergeordnet wird; sonst entstuende ein Ring und jede Baumdarstellung liefe
endlos. Der Produktfilter schliesst Unterkategorien ein, category_id=0 liefert
die Artikel ohne Kategorie. Export und Import fuehren die Kategorie als Pfad
("Suesswaren & Snacks > Schokolade") in einer Spalte, damit die CSV in Excel
bedienbar bleibt.
Web: neue Seite Kategorien mit Baumdarstellung, Filter ueber der Produktliste,
getrennte Auswahlfelder im Produktformular mit je einer Zeile Erklaerung, und
beim Einlagern laesst sich eine Gruppe samt Einheit und Mindestbestand direkt
anlegen, ohne den Vorgang zu verlassen.
iOS: Kategorie-Filter ueber der Produktliste, Unterkategorien eingerueckt. Die
Artikelzeile nennt jetzt das Gebinde und warnt, wenn der Mindestbestand
unterschritten ist (rot) oder weniger als ein Viertel Luft bleibt (orange).
Getrennte Auswahlfelder fuer Kategorie und Gruppe, Gruppe direkt anlegbar.
Ausserdem die Eingabefelder in der App: .textFieldStyle(.roundedBorder) zeichnet
in der dunklen Darstellung einen fast schwarzen Kasten. Ersetzt durch eine
Systemfuellung, die sich Hell und Dunkel anpasst und zurueckhaltend bleibt.
Getestet: 54 pytest-Tests gruen, 14 davon neu (Nachfahren-Sammler, OFF-Zuordnung
inklusive Vorrang der Unterkategorie, und die komplette Codepflege an der
Gruppe). Gegen die laufende API geprueft: Startliste ohne Dubletten, Filter auf
Ober- und Unterkategorie, Ringschutz, Loeschen einer Kategorie laesst Artikel
und Unterkategorien bestehen, Export/Import-Rundlauf mit Kategoriepfad, und der
Durchlauf aus der Meldung - Artikel mit Gruppe anlegen, Code steht danach in der
Gruppe. Web-Build und iOS-Geraetebuild fehler- und warnungsfrei.
Die Oberflaechen habe ich nicht selbst bedient.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
165
web/src/pages/Categories.jsx
Normal file
165
web/src/pages/Categories.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { asTree } from "../categoryTree";
|
||||
|
||||
/**
|
||||
* Kategorien ordnen die Artikelliste – mehr nicht.
|
||||
*
|
||||
* Bewusst ohne Bestand, Mindestbestand und EAN-Codes: Das ist Sache der
|
||||
* Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein.
|
||||
*/
|
||||
export default function Categories() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", parent_id: "" });
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setCategories(await api.listCategories());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createCategory({
|
||||
name: form.name,
|
||||
parent_id: form.parent_id === "" ? null : Number(form.parent_id),
|
||||
});
|
||||
setForm({ name: "", parent_id: form.parent_id });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function patch(category, body) {
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateCategory(category.id, body);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(category) {
|
||||
const hinweis = category.product_count
|
||||
? ` ${category.product_count} Artikel verlieren die Zuordnung, bleiben aber erhalten.`
|
||||
: "";
|
||||
if (!confirm(`Kategorie "${category.name}" löschen?${hinweis}`)) return;
|
||||
try {
|
||||
await api.deleteCategory(category.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const tree = asTree(categories);
|
||||
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Kategorien</h1>
|
||||
<div className="sub">
|
||||
Ordnen die Artikelliste für den Überblick – „zeig mir alle Süßwaren".
|
||||
Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das
|
||||
übernehmen die Gruppen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th className="num">Artikel</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tree.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8,
|
||||
paddingLeft: c.depth * 22 }}>
|
||||
{c.depth > 0 && <span className="muted">↳</span>}
|
||||
{isAdmin ? (
|
||||
<input defaultValue={c.name} style={{ marginTop: 0, minWidth: 150 }}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== c.name) patch(c, { name: v });
|
||||
}} />
|
||||
) : <span className="strong">{c.name}</span>}
|
||||
{c.depth > 0 && c.parent_id && nameById[c.parent_id] && (
|
||||
<span className="badge">in {nameById[c.parent_id]}</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num muted">{c.product_count}</td>
|
||||
<td className="num">
|
||||
{isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
<tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isAdmin && (
|
||||
<form className="card" onSubmit={add}>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} placeholder="z.B. Süßwaren & Snacks"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Untergeordnet (optional)
|
||||
<select value={form.parent_id}
|
||||
onChange={(e) => setForm({ ...form, parent_id: e.target.value })}>
|
||||
<option value="">– oberste Ebene –</option>
|
||||
{tree.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{"— ".repeat(c.depth)}{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
<p className="muted small">
|
||||
Unterkategorien sind beliebig tief möglich. Filterst du später auf eine
|
||||
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit.
|
||||
Namen lassen sich in der Tabelle direkt ändern.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup, suggestionToProduct } from "../offUtils";
|
||||
import { suggestionToProduct } from "../offUtils";
|
||||
import { asTree } from "../categoryTree";
|
||||
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
||||
|
||||
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||
@@ -13,13 +14,19 @@ export default function CheckIn() {
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [product, setProduct] = useState(null);
|
||||
const [suggestion, setSuggestion] = useState(null);
|
||||
// Kategorie, die diesem Code zugeordnet ist (heisst in der API weiterhin "group").
|
||||
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl").
|
||||
const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" });
|
||||
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
||||
const [results, setResults] = useState([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
// Auswahl fuer den Artikel, der aus dem Scan angelegt werden soll.
|
||||
const [newGroupId, setNewGroupId] = useState("");
|
||||
const [newCategoryId, setNewCategoryId] = useState("");
|
||||
// Kleines Formular, um eine Gruppe direkt hier anzulegen.
|
||||
const [groupDraft, setGroupDraft] = useState(null);
|
||||
const [units, setUnits] = useState([]);
|
||||
|
||||
const [unit, setUnit] = useState("");
|
||||
@@ -36,12 +43,14 @@ export default function CheckIn() {
|
||||
useEffect(() => {
|
||||
api.listLocations().then(setLocations).catch(() => {});
|
||||
api.listGroups().then(setGroups).catch(() => {});
|
||||
api.listCategories().then(setCategories).catch(() => {});
|
||||
api.listUnits().then(setUnits).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function resetLookup() {
|
||||
setSuggestion(null);
|
||||
setUnknownBarcode(null);
|
||||
setGroupDraft(null);
|
||||
}
|
||||
|
||||
function selectProduct(p) {
|
||||
@@ -65,12 +74,14 @@ export default function CheckIn() {
|
||||
if (res.found && res.existing_product) {
|
||||
selectProduct(res.existing_product);
|
||||
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
||||
} else if (res.found && res.suggestion) {
|
||||
setSuggestion(res.suggestion);
|
||||
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
||||
} else {
|
||||
setUnknownBarcode(barcode.trim());
|
||||
// Gruppe kommt ausschliesslich aus einem hinterlegten Gruppen-Code,
|
||||
// die Kategorie aus der Open-Food-Facts-Einordnung.
|
||||
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
||||
setNewGroupId(res.group_id ? String(res.group_id) : "");
|
||||
setNewCategoryId(res.category_id ? String(res.category_id) : "");
|
||||
if (res.found && res.suggestion) setSuggestion(res.suggestion);
|
||||
else setUnknownBarcode(barcode.trim());
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -80,15 +91,17 @@ export default function CheckIn() {
|
||||
async function createFromSuggestion() {
|
||||
setError(null); setBusy(true);
|
||||
try {
|
||||
// Ist der Code einer Kategorie zugeordnet, hat das Vorrang vor dem Rateversuch aus OFF.
|
||||
const groupId = suggestionGroup.id || guessGroup(groups, suggestion);
|
||||
const payload = suggestionToProduct(suggestion, groupId);
|
||||
const payload = suggestionToProduct(suggestion, newGroupId, newCategoryId);
|
||||
const created = await api.createProduct(payload);
|
||||
selectProduct(created);
|
||||
const kategorie = groups.find((g) => String(g.id) === String(created.group_id));
|
||||
const gruppe = groups.find((g) => String(g.id) === String(created.group_id));
|
||||
const kategorie = categories.find((c) => String(c.id) === String(created.category_id));
|
||||
setInfo(
|
||||
`Produkt "${created.name}" angelegt` +
|
||||
(kategorie ? ` und der Kategorie "${kategorie.name}" zugeordnet.` : " (ohne Kategorie).")
|
||||
(kategorie ? `, Kategorie "${kategorie.name}"` : "") +
|
||||
(gruppe
|
||||
? `, Gruppe "${gruppe.name}" – der EAN-Code steht jetzt auch bei der Gruppe.`
|
||||
: " (ohne Gruppe).")
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -97,6 +110,92 @@ export default function CheckIn() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gruppe an Ort und Stelle anlegen, damit man den Vorgang nicht verlassen muss. */
|
||||
async function createGroup(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.createGroup({
|
||||
name: groupDraft.name,
|
||||
min_stock: groupDraft.min_stock === "" ? null : Number(groupDraft.min_stock),
|
||||
min_stock_unit_id:
|
||||
groupDraft.min_stock_unit_id === "" ? null : Number(groupDraft.min_stock_unit_id),
|
||||
});
|
||||
setGroups(await api.listGroups());
|
||||
setNewGroupId(String(created.id));
|
||||
setGroupDraft(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zuordnung des neu anzulegenden Artikels: Kategorie und Gruppe. */
|
||||
function assignFields() {
|
||||
return (
|
||||
<div className="assign-box">
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Kategorie
|
||||
<select value={newCategoryId} onChange={(e) => setNewCategoryId(e.target.value)}>
|
||||
<option value="">– keine –</option>
|
||||
{asTree(categories).map((c) => (
|
||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="muted small">Nur für den Überblick in der Artikelliste.</span>
|
||||
</label>
|
||||
<label className="grow">
|
||||
Gruppe
|
||||
<select value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)}>
|
||||
<option value="">– keine –</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
<span className="muted small">
|
||||
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code landet dann auch dort.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isAdmin && !groupDraft && (
|
||||
<button type="button" className="btn sm"
|
||||
onClick={() => setGroupDraft({ name: "", min_stock: "", min_stock_unit_id: "" })}>
|
||||
<Icon name="plus" size={14} />Neue Gruppe anlegen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{groupDraft && (
|
||||
<form className="card" style={{ marginTop: "var(--sp-3)" }} onSubmit={createGroup}>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Name
|
||||
<input value={groupDraft.name} placeholder="z.B. Mehl" required
|
||||
onChange={(e) => setGroupDraft({ ...groupDraft, name: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
Mindestbestand (optional)
|
||||
<input type="number" step="any" value={groupDraft.min_stock} placeholder="z.B. 2"
|
||||
onChange={(e) => setGroupDraft({ ...groupDraft, min_stock: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
Einheit
|
||||
<select value={groupDraft.min_stock_unit_id}
|
||||
onChange={(e) => setGroupDraft({ ...groupDraft, min_stock_unit_id: e.target.value })}>
|
||||
<option value="">– Basiseinheit –</option>
|
||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="btn-pair">
|
||||
<button type="button" className="btn ghost" onClick={() => setGroupDraft(null)}>Abbrechen</button>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function doSearch(e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -191,7 +290,7 @@ export default function CheckIn() {
|
||||
{suggestionGroup.name && (
|
||||
<div className="assign-note">
|
||||
<Icon name="tag" size={14} />
|
||||
Wird automatisch der Kategorie <strong>{suggestionGroup.name}</strong> zugeordnet
|
||||
Wird automatisch der Gruppe <strong>{suggestionGroup.name}</strong> zugeordnet
|
||||
<span className="muted small"> – weil dieser EAN-Code dort hinterlegt ist.</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -206,6 +305,8 @@ export default function CheckIn() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestion && isAdmin && assignFields()}
|
||||
|
||||
{unknownBarcode && (
|
||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
@@ -218,13 +319,15 @@ export default function CheckIn() {
|
||||
{suggestionGroup.name && (
|
||||
<div className="assign-note">
|
||||
<Icon name="tag" size={14} />
|
||||
Wird automatisch der Kategorie <strong>{suggestionGroup.name}</strong> zugeordnet
|
||||
Wird automatisch der Gruppe <strong>{suggestionGroup.name}</strong> zugeordnet
|
||||
<span className="muted small"> – weil dieser EAN-Code dort hinterlegt ist.</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unknownBarcode && isAdmin && assignFields()}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function Dashboard() {
|
||||
<tbody>
|
||||
{groupShopping.map((it) => (
|
||||
<tr key={`g${it.group_id}`}>
|
||||
<td><span className="badge accent">Kategorie</span> {it.name}</td>
|
||||
<td><span className="badge accent">Gruppe</span> {it.name}</td>
|
||||
<td className="num">{fmt(it.stock)} {it.unit_name}</td>
|
||||
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
||||
</tr>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Groups() {
|
||||
}
|
||||
|
||||
async function remove(group) {
|
||||
if (!confirm(`Kategorie "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return;
|
||||
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return;
|
||||
try {
|
||||
await api.deleteGroup(group.id);
|
||||
if (selectedId === group.id) setSelectedId(null);
|
||||
@@ -68,9 +68,9 @@ export default function Groups() {
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Kategorien</h1>
|
||||
<h1>Gruppen</h1>
|
||||
<div className="sub">
|
||||
Produkte zusammenfassen, Mindestbestand je Kategorie setzen und EAN-Codes hinterlegen
|
||||
Bestände mehrerer Marken zusammenzählen – z.B. Mehl von Rewe, Aldi und Migros als „5 kg Mehl". Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ export default function Groups() {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Gruppe</th>
|
||||
<th className="num">Produkte</th>
|
||||
<th className="num">Bestand</th>
|
||||
<th>Mindestbestand</th>
|
||||
@@ -133,7 +133,7 @@ export default function Groups() {
|
||||
</td>
|
||||
<td className="num">
|
||||
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
||||
{/* Beide Herkünfte zählen, sonst steht bei einer Kategorie
|
||||
{/* Beide Herkünfte zählen, sonst steht bei einer Gruppe
|
||||
mit Artikeln irreführend eine 0. */}
|
||||
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
|
||||
</button>
|
||||
@@ -148,7 +148,7 @@ export default function Groups() {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Kategorien.</td></tr>}
|
||||
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Gruppen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -159,16 +159,15 @@ export default function Groups() {
|
||||
<section className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="search" />
|
||||
<h2>EAN-Codes: {selected.name}</h2>
|
||||
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
|
||||
</div>
|
||||
<BarcodeList
|
||||
barcodes={selected.barcodes || []}
|
||||
productBarcodes={selected.product_barcodes || []}
|
||||
disabled={!isAdmin}
|
||||
hint={"Mit „Artikel“ gekennzeichnete Codes gehören einem Artikel dieser Kategorie " +
|
||||
"und lassen sich hier nicht ändern. Selbst hinterlegte Codes wirken nur für " +
|
||||
"noch unbekannte Artikel: Wird so ein Code beim Einlagern gescannt, landet " +
|
||||
"der neu angelegte Artikel automatisch in dieser Kategorie."}
|
||||
hint={"Codes der zugeordneten Artikel stehen automatisch hier. Zusätzlich kannst du Codes " +
|
||||
"hinterlegen, die noch zu keinem Artikel gehören: Wird so ein Code beim " +
|
||||
"Einlagern gescannt, landet der neu angelegte Artikel automatisch in dieser Gruppe."}
|
||||
onAdd={async (body) => {
|
||||
try {
|
||||
await api.addGroupBarcode(selected.id, body);
|
||||
@@ -188,7 +187,7 @@ export default function Groups() {
|
||||
|
||||
{isAdmin && (
|
||||
<form className="card" onSubmit={add}>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||
<label>
|
||||
Name
|
||||
<input placeholder="z.B. Mehl" value={form.name}
|
||||
@@ -211,7 +210,7 @@ export default function Groups() {
|
||||
</div>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
<p className="muted small">
|
||||
Der Bestand der Kategorie summiert nur Produkte, die zur gewählten Einheit passen.
|
||||
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen.
|
||||
Namen lassen sich in der Tabelle direkt ändern.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -4,15 +4,16 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import BarcodeList from "../components/BarcodeList";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup } from "../offUtils";
|
||||
import { useSettings } from "../settings";
|
||||
import { asTree } from "../categoryTree";
|
||||
import {
|
||||
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort,
|
||||
} from "../units";
|
||||
|
||||
const EMPTY = {
|
||||
barcode: "", name: "", brand: "", image_url: "",
|
||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", group_id: "",
|
||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
||||
group_id: "", category_id: "",
|
||||
};
|
||||
|
||||
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
||||
@@ -37,6 +38,7 @@ export default function ProductForm() {
|
||||
const [product, setProduct] = useState(null);
|
||||
const [lots, setLots] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
@@ -76,8 +78,8 @@ export default function ProductForm() {
|
||||
setMinUnit(newSel);
|
||||
}
|
||||
|
||||
function applySuggestion(s, groupsList, unitList) {
|
||||
const groupGuess = guessGroup(groupsList, s);
|
||||
function applySuggestion(res, unitList) {
|
||||
const s = res.suggestion;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
barcode: s.barcode || f.barcode,
|
||||
@@ -86,17 +88,21 @@ export default function ProductForm() {
|
||||
image_url: s.image_url || f.image_url,
|
||||
unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
|
||||
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
|
||||
group_id: f.group_id || groupGuess,
|
||||
// Kategorie schlaegt das Backend aus der OFF-Einordnung vor; die Gruppe
|
||||
// nur, wenn der Code dort ausdruecklich hinterlegt ist.
|
||||
category_id: f.category_id || (res.category_id ? String(res.category_id) : ""),
|
||||
group_id: f.group_id || (res.group_id ? String(res.group_id) : ""),
|
||||
}));
|
||||
if (s.package_size != null) setMinUnit("package");
|
||||
setInfo(
|
||||
"Daten von Open Food Facts übernommen." +
|
||||
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") +
|
||||
(groupGuess ? " Passende Kategorie vorgeschlagen." : "")
|
||||
(res.category_name ? ` Kategorie „${res.category_name}" vorgeschlagen.` : "") +
|
||||
(res.group_name ? ` Gruppe „${res.group_name}" über den EAN-Code erkannt.` : "")
|
||||
);
|
||||
}
|
||||
|
||||
async function runLookup(code, groupsList, unitList) {
|
||||
async function runLookup(code, unitList) {
|
||||
if (!code) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
@@ -106,7 +112,7 @@ export default function ProductForm() {
|
||||
setInfo("Dieses Produkt existiert bereits.");
|
||||
navigate(`/products/${res.existing_product.id}`);
|
||||
} else if (res.found && res.suggestion) {
|
||||
applySuggestion(res.suggestion, groupsList, unitList);
|
||||
applySuggestion(res, unitList);
|
||||
} else {
|
||||
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
||||
}
|
||||
@@ -118,9 +124,12 @@ export default function ProductForm() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
|
||||
const [gs, us, cs] = await Promise.all([
|
||||
api.listGroups(), api.listUnits(), api.listCategories(),
|
||||
]);
|
||||
setGroups(gs);
|
||||
setUnits(us);
|
||||
setCategories(cs);
|
||||
try {
|
||||
const s = await api.listSettings();
|
||||
const row = s.find((x) => x.key === "expiry_warning_days");
|
||||
@@ -148,13 +157,14 @@ export default function ProductForm() {
|
||||
date_precision: p.date_precision === "month" ? "month" : "day",
|
||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||
group_id: p.group_id ?? "",
|
||||
category_id: p.category_id ?? "",
|
||||
});
|
||||
setLots(await api.listLots(id));
|
||||
} else {
|
||||
const bc = searchParams.get("barcode");
|
||||
if (bc) {
|
||||
set("barcode", bc);
|
||||
await runLookup(bc, gs, us);
|
||||
await runLookup(bc, us);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -184,6 +194,7 @@ export default function ProductForm() {
|
||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||
min_stock_in_packages: sel === "package",
|
||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||
category_id: form.category_id === "" ? null : Number(form.category_id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,13 +338,33 @@ export default function ProductForm() {
|
||||
</div>
|
||||
</label>
|
||||
<label className="grow">
|
||||
Kategorie
|
||||
Gruppe
|
||||
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
||||
<option value="">– keine –</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
<span className="muted small">
|
||||
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code dieses Artikels
|
||||
erscheint danach automatisch bei der Gruppe.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Kategorie
|
||||
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– keine –</option>
|
||||
{asTree(categories).map((c) => (
|
||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="muted small">
|
||||
Nur für den Überblick in der Artikelliste – ohne Einfluss auf Bestände.
|
||||
</span>
|
||||
</label>
|
||||
<div className="grow" />
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitShort } from "../units";
|
||||
import { asTree } from "../categoryTree";
|
||||
|
||||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||||
function unitCount(p) {
|
||||
@@ -19,11 +20,14 @@ export default function Products() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [products, setProducts] = useState([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [categories, setCategories] = useState([]);
|
||||
// "" = alle, "0" = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
async function load(cat = categoryId) {
|
||||
try {
|
||||
setProducts(await api.listProducts(q));
|
||||
setProducts(await api.listProducts(q, cat));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -31,6 +35,7 @@ export default function Products() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
api.listCategories().then(setCategories).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -53,10 +58,19 @@ export default function Products() {
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 420 }} onSubmit={onSearch}>
|
||||
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 640 }} onSubmit={onSearch}>
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ margin: 0 }}>
|
||||
<select value={categoryId} onChange={(e) => { setCategoryId(e.target.value); load(e.target.value); }}>
|
||||
<option value="">Alle Kategorien</option>
|
||||
<option value="0">Ohne Kategorie</option>
|
||||
{asTree(categories).map((c) => (
|
||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||||
</form>
|
||||
|
||||
@@ -68,6 +82,7 @@ export default function Products() {
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Marke</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Einheit</th>
|
||||
<th className="num">Bestand</th>
|
||||
<th className="num">Einheiten (ist / soll)</th>
|
||||
@@ -93,6 +108,7 @@ export default function Products() {
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{p.brand || "–"}</td>
|
||||
<td className="muted">{p.category_name || "–"}</td>
|
||||
<td>
|
||||
{p.unit_name || unitShort(p.base_unit)}
|
||||
{p.package_size
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ShoppingList() {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Einkaufsliste</h1>
|
||||
<div className="sub">Produkte und Kategorien unter ihrem Mindestbestand</div>
|
||||
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
@@ -42,7 +42,7 @@ export default function ShoppingList() {
|
||||
<li key={key} className={checked[key] ? "done" : ""}>
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||
<span className="badge accent">Kategorie</span>
|
||||
<span className="badge accent">Gruppe</span>
|
||||
<span className="item-name">{it.name}</span>
|
||||
</label>
|
||||
<span className="muted small">
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function Transfer() {
|
||||
<p className="muted small mt-0">
|
||||
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
|
||||
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
|
||||
Einheiten, Kategorien und Lagerorten.
|
||||
Einheiten, Gruppen und Lagerorten.
|
||||
</p>
|
||||
<div className="field-inline">
|
||||
<button className="btn primary" onClick={() => download("csv")}>
|
||||
@@ -84,7 +84,7 @@ export default function Transfer() {
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
|
||||
<p className="muted small mt-0">
|
||||
CSV oder JSON auswählen. Unbekannte Produkte, Kategorien und Lagerorte werden immer
|
||||
CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer
|
||||
angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.
|
||||
</p>
|
||||
<form onSubmit={doImport}>
|
||||
|
||||
Reference in New Issue
Block a user