Gebinde verwalten - mit Einzahl und Mehrzahl
Bisher war die Auswahl eine fest im Frontend verdrahtete Liste und es gab nur eine Form: ueberall stand "3 Glas". Neu unter Verwaltung > Gebinde: anlegen, umbenennen, loeschen, jeweils mit Einzahl und Mehrzahl. Die eingebauten Gebinde lassen sich in der Schreibweise aendern, aber nicht loeschen; ein Gebinde, das ein Artikel verwendet, ebenfalls nicht. Der Artikel speichert weiterhin nur die Einzahl als Text - so bleiben vorhandene Artikel, Sicherungen und CSV-Dateien gueltig, und eine unbekannte Bezeichnung faellt schlicht auf die Einzahl zurueck. Deshalb zieht ein Umbenennen die Artikel mit; sonst zeigten sie auf eine Bezeichnung, die es nicht mehr gibt. Die Mehrzahl greift jetzt in Artikelliste, Artikelseite (Chargen und Gebinde- Auswahl), Auslagern und in allen Ablauf- und Einkaufslisten samt Startseite. Einheiten wie Gramm oder Liter bleiben unveraendert - die haben im Deutschen keine Mehrzahl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import CheckOut from "./pages/CheckOut";
|
||||
import Groups from "./pages/Groups";
|
||||
import Categories from "./pages/Categories";
|
||||
import Locations from "./pages/Locations";
|
||||
import PackageTypes from "./pages/PackageTypes";
|
||||
import Units from "./pages/Units";
|
||||
import Users from "./pages/Users";
|
||||
import ShoppingList from "./pages/ShoppingList";
|
||||
@@ -57,6 +58,7 @@ function Sidebar() {
|
||||
<div className="nav-section">Verwaltung</div>
|
||||
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
||||
<NavItem to="/units" icon="box" label="Einheiten" />
|
||||
<NavItem to="/package-types" icon="package" label="Gebinde" />
|
||||
<NavItem to="/users" icon="users" label="Benutzer" />
|
||||
<NavItem to="/transfer" icon="history" label="Import / Export" />
|
||||
<NavItem to="/settings" icon="settings" label="Einstellungen" />
|
||||
@@ -112,6 +114,7 @@ export default function App() {
|
||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
|
||||
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||
|
||||
@@ -191,6 +191,12 @@ export const api = {
|
||||
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
|
||||
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Gebinde (Einzahl/Mehrzahl): "Glas" -> "Gläser"
|
||||
listPackageTypes: () => request("/package-types"),
|
||||
createPackageType: (body) => request("/package-types", { method: "POST", body }),
|
||||
updatePackageType: (id, body) => request(`/package-types/${id}`, { method: "PATCH", body }),
|
||||
deletePackageType: (id) => request(`/package-types/${id}`, { method: "DELETE" }),
|
||||
|
||||
listGroups: () => request("/groups"),
|
||||
createGroup: (body) => request("/groups", { method: "POST", body }),
|
||||
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
||||
|
||||
@@ -4,7 +4,7 @@ import Icon from "../components/Icon";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { useToast } from "../toast";
|
||||
import { useSettings } from "../settings";
|
||||
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
||||
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort } from "../units";
|
||||
|
||||
export default function CheckOut() {
|
||||
const { formatBestBefore } = useSettings();
|
||||
@@ -98,7 +98,7 @@ export default function CheckOut() {
|
||||
|
||||
// Chargenmenge in der Artikeleinheit (Gebinde), sonst in der Produkteinheit.
|
||||
function lotAmount(q) {
|
||||
if (pkgSize > 0) return `${fmt(q / pkgSize)} ${pkgLabel}`;
|
||||
if (pkgSize > 0) return `${fmt(q / pkgSize)} ${gebinde(q / pkgSize, pkgLabel)}`;
|
||||
return `${fmt(q / (product?.unit_factor || 1))} ${product?.unit_name || baseShort}`;
|
||||
}
|
||||
|
||||
|
||||
175
web/src/pages/PackageTypes.jsx
Normal file
175
web/src/pages/PackageTypes.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import Icon from "../components/Icon";
|
||||
import { useSettings } from "../settings";
|
||||
|
||||
/**
|
||||
* Gebinde verwalten: Einzahl und Mehrzahl.
|
||||
*
|
||||
* Die Mehrzahl ist der eigentliche Zweck – ohne sie stünde überall „3 Glas“.
|
||||
* Sie lässt sich auch für eingebaute Gebinde ändern; nur Löschen bleibt den
|
||||
* selbst angelegten vorbehalten.
|
||||
*/
|
||||
export default function PackageTypes() {
|
||||
const confirm = useConfirm();
|
||||
const { reload: reloadSettings } = useSettings();
|
||||
const [arten, setArten] = useState([]);
|
||||
const [form, setForm] = useState({ singular: "", plural: "" });
|
||||
const [bearbeitung, setBearbeitung] = useState(null); // { id, singular, plural }
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setArten(await api.listPackageTypes());
|
||||
// Die Mehrzahlformen stecken in einer Modulvariablen, die beim Login
|
||||
// gefuellt wird - nach einer Aenderung muss sie neu geladen werden,
|
||||
// sonst zeigen andere Seiten weiter die alte Form.
|
||||
reloadSettings();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function anlegen(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createPackageType({
|
||||
singular: form.singular.trim(),
|
||||
plural: form.plural.trim() || form.singular.trim(),
|
||||
});
|
||||
setForm({ singular: "", plural: "" });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
setError(null);
|
||||
try {
|
||||
await api.updatePackageType(bearbeitung.id, {
|
||||
singular: bearbeitung.singular.trim(),
|
||||
plural: bearbeitung.plural.trim(),
|
||||
});
|
||||
setBearbeitung(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen(art) {
|
||||
const ok = await confirm({
|
||||
title: `Gebinde „${art.singular}“ löschen?`,
|
||||
message: "Nur möglich, solange kein Artikel es verwendet.",
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deletePackageType(art.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Gebinde</h1>
|
||||
<div className="sub">
|
||||
Bezeichnung eines Artikelgebindes – die Mehrzahl wird überall dort
|
||||
verwendet, wo mehr als eines gemeint ist.
|
||||
</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>Einzahl</th><th>Mehrzahl</th><th>Beispiel</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{arten.map((art) => {
|
||||
const offen = bearbeitung?.id === art.id;
|
||||
return (
|
||||
<tr key={art.id}>
|
||||
<td data-label="Einzahl" className="strong">
|
||||
{offen ? (
|
||||
<input value={bearbeitung.singular}
|
||||
onChange={(e) => setBearbeitung({ ...bearbeitung, singular: e.target.value })} />
|
||||
) : (
|
||||
<span className="cell-row">
|
||||
<span>{art.singular}</span>
|
||||
{art.is_builtin && <span className="badge nowrap">eingebaut</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Mehrzahl">
|
||||
{offen ? (
|
||||
<input value={bearbeitung.plural}
|
||||
onChange={(e) => setBearbeitung({ ...bearbeitung, plural: e.target.value })} />
|
||||
) : art.plural}
|
||||
</td>
|
||||
<td data-label="Beispiel" className="muted">
|
||||
1 {art.singular} · 3 {art.plural}
|
||||
</td>
|
||||
<td className="num">
|
||||
{offen ? (
|
||||
<span className="cell-row">
|
||||
<button type="button" className="btn sm primary" onClick={speichern}>Speichern</button>
|
||||
<button type="button" className="btn sm ghost" onClick={() => setBearbeitung(null)}>Abbrechen</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="cell-row">
|
||||
<button type="button" className="btn-icon" title="Bearbeiten"
|
||||
onClick={() => setBearbeitung({ ...art })}>
|
||||
<Icon name="edit" size={16} />
|
||||
</button>
|
||||
{!art.is_builtin && (
|
||||
<button type="button" className="btn-icon danger" title="Löschen"
|
||||
onClick={() => loeschen(art)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="card" onSubmit={anlegen}>
|
||||
<div className="card-head"><Icon name="package" /><h2>Neues Gebinde</h2></div>
|
||||
<label>
|
||||
Einzahl
|
||||
<input placeholder="z.B. Kanister" value={form.singular} required
|
||||
onChange={(e) => setForm({ ...form, singular: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Mehrzahl
|
||||
<input placeholder="z.B. Kanister" value={form.plural} required
|
||||
onChange={(e) => setForm({ ...form, plural: e.target.value })} />
|
||||
<span className="muted small">
|
||||
Lauten beide gleich, hier dasselbe Wort eintragen.
|
||||
</span>
|
||||
</label>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import { useToast } from "../toast";
|
||||
import { useSettings } from "../settings";
|
||||
import { asTree } from "../categoryTree";
|
||||
import {
|
||||
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort,
|
||||
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
|
||||
toMonthInput, unitShort,
|
||||
} from "../units";
|
||||
|
||||
const EMPTY = {
|
||||
@@ -20,10 +21,7 @@ const EMPTY = {
|
||||
group_id: "", category_id: "",
|
||||
};
|
||||
|
||||
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
||||
const PACKAGE_LABELS = [
|
||||
"Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton", "Riegel", "Rolle",
|
||||
];
|
||||
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
|
||||
|
||||
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
|
||||
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
|
||||
@@ -46,6 +44,7 @@ export default function ProductForm() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [gebindearten, setGebindearten] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
|
||||
@@ -204,12 +203,13 @@ export default function ProductForm() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [gs, us, cs] = await Promise.all([
|
||||
api.listGroups(), api.listUnits(), api.listCategories(),
|
||||
const [gs, us, cs, pts] = await Promise.all([
|
||||
api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(),
|
||||
]);
|
||||
setGroups(gs);
|
||||
setUnits(us);
|
||||
setCategories(cs);
|
||||
setGebindearten(pts);
|
||||
try {
|
||||
const s = await api.listSettings();
|
||||
const row = s.find((x) => x.key === "expiry_warning_days");
|
||||
@@ -415,12 +415,18 @@ export default function ProductForm() {
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Bezeichnung der Einheit
|
||||
<span className="tip" title="Verwaltet unter Verwaltung → Gebinde, dort auch mit Mehrzahl.">
|
||||
Gebinde
|
||||
</span>
|
||||
<select value={form.package_label} onChange={(e) => set("package_label", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">Packung (Standard)</option>
|
||||
{PACKAGE_LABELS.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
{form.package_label && !PACKAGE_LABELS.includes(form.package_label) && (
|
||||
{gebindearten.map((g) => (
|
||||
<option key={g.id} value={g.singular}>{g.singular} / {g.plural}</option>
|
||||
))}
|
||||
{/* Ein Artikel kann eine Bezeichnung tragen, die es in der
|
||||
Verwaltung (noch) nicht gibt – etwa aus einem Import. */}
|
||||
{form.package_label && !gebindearten.some((g) => g.singular === form.package_label) && (
|
||||
<option value={form.package_label}>{form.package_label}</option>
|
||||
)}
|
||||
</select>
|
||||
@@ -555,7 +561,9 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onEr
|
||||
// Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist –
|
||||
// sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein.
|
||||
const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor;
|
||||
const primaryLabel = pkgSize > 0 ? pkgLabel : unitName;
|
||||
// Mengenabhaengig: "1 Glas", aber "3 Gläser". Einheiten (Gramm, Liter)
|
||||
// bleiben unveraendert - die haben im Deutschen keine Mehrzahl.
|
||||
const primaryLabel = (menge) => (pkgSize > 0 ? gebinde(menge, pkgLabel) : unitName);
|
||||
|
||||
function secondary(qty) {
|
||||
if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`;
|
||||
@@ -667,7 +675,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onEr
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{fmt(l.quantity / primaryFactor)} {primaryLabel}
|
||||
{fmt(l.quantity / primaryFactor)} {primaryLabel(l.quantity / primaryFactor)}
|
||||
{secondary(l.quantity) && (
|
||||
<div className="muted small">{secondary(l.quantity)}</div>
|
||||
)}
|
||||
|
||||
@@ -4,16 +4,17 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { fmt, unitShort } from "../units";
|
||||
import { fmt, gebinde, unitShort } from "../units";
|
||||
import { asTree } from "../categoryTree";
|
||||
|
||||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||||
function unitCount(p) {
|
||||
return p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1);
|
||||
}
|
||||
function countLabel(p) {
|
||||
// Mengenabhaengig, damit "3 Gläser" dasteht und nicht "3 Glas".
|
||||
function countLabel(p, menge) {
|
||||
return p.package_size && p.package_size > 0
|
||||
? (p.package_label || "Packung")
|
||||
? gebinde(menge, p.package_label)
|
||||
: (p.unit_name || unitShort(p.base_unit));
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ export default function Products() {
|
||||
<span>
|
||||
{fmt(p.stock / unitCount(p))}
|
||||
{p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "}
|
||||
{countLabel(p)}
|
||||
{countLabel(p, p.stock / unitCount(p))}
|
||||
</span>
|
||||
{low && <span className="badge warn">niedrig</span>}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { formatBestBefore, formatDate } from "./units";
|
||||
import { formatBestBefore, formatDate, setGebindeFormen } from "./units";
|
||||
|
||||
export const DATE_FORMAT_KEY = "date_format";
|
||||
const DEFAULT_FORMAT = "de";
|
||||
@@ -26,6 +26,13 @@ export function SettingsProvider({ children }) {
|
||||
} catch {
|
||||
/* Einstellungen sind optional – Standard bleibt bestehen. */
|
||||
}
|
||||
try {
|
||||
// Mehrzahlformen der Gebinde. Sie landen in einer Modulvariablen, weil
|
||||
// auch Nicht-Komponenten (die Formatierer in units.js) sie brauchen.
|
||||
setGebindeFormen(await api.listPackageTypes());
|
||||
} catch {
|
||||
/* Ohne Liste bleibt die Einzahl stehen – unschoen, aber nicht falsch. */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
// Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen).
|
||||
|
||||
// ---- Gebinde: Einzahl und Mehrzahl ----
|
||||
// Der Artikel speichert nur die Einzahl ("Glas"); die Mehrzahl steht in der
|
||||
// Verwaltung (Tabelle package_types) und wird nach dem Login einmal geladen.
|
||||
//
|
||||
// Bewusst eine Modulvariable und kein React-Kontext: Die Formatierer hier sind
|
||||
// gewöhnliche Funktionen, die auch außerhalb von Komponenten aufgerufen werden.
|
||||
// Sie alle in Hooks zu verwandeln, hieße jede Aufrufstelle umzubauen, um eine
|
||||
// Liste durchzureichen, die sich praktisch nie ändert.
|
||||
let GEBINDE_MEHRZAHL = {};
|
||||
|
||||
export function setGebindeFormen(liste) {
|
||||
GEBINDE_MEHRZAHL = Object.fromEntries(
|
||||
(liste || []).map((t) => [t.singular, t.plural || t.singular]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gebinde-Bezeichnung passend zur Menge: "1 Glas", "3 Gläser".
|
||||
*
|
||||
* Unbekannte Bezeichnungen bleiben unverändert – lieber eine fehlende Mehrzahl
|
||||
* als ein erfundenes "Glass". Nicht ganze Mengen zählen als Mehrzahl
|
||||
* ("0,5 Gläser"), so wie im Deutschen alles außer der Eins.
|
||||
*/
|
||||
export function gebinde(menge, einzahl) {
|
||||
const wort = einzahl || "Packung";
|
||||
if (Math.abs(Number(menge)) === 1) return wort;
|
||||
return GEBINDE_MEHRZAHL[wort] || wort;
|
||||
}
|
||||
|
||||
|
||||
export const BASE_UNITS = [
|
||||
{ value: "piece", label: "Stück" },
|
||||
{ value: "gram", label: "Gramm (g)" },
|
||||
@@ -173,7 +203,8 @@ export function buildUnitOptions(product, units) {
|
||||
// Erwartet ein Objekt mit quantity, package_size, package_label, unit_name, unit_factor, base_unit.
|
||||
export function articlePrimary(item) {
|
||||
if (item.package_size > 0) {
|
||||
return `${fmt(item.quantity / item.package_size)} ${item.package_label || "Packung"}`;
|
||||
const anzahl = item.quantity / item.package_size;
|
||||
return `${fmt(anzahl)} ${gebinde(anzahl, item.package_label)}`;
|
||||
}
|
||||
return `${fmt(item.quantity / (item.unit_factor || 1))} ${item.unit_name || unitShort(item.base_unit)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user