Gebinde-Bezeichnung je Produkt + Spalte "Einheiten (ist / soll)"
- Neues Feld products.package_label: pro Artikel einstellbar, wie das Gebinde heisst (Packung, Glas, Tuete, Flasche, Dose, Tube, ...) - Eingabe mit Vorschlagsliste, frei ueberschreibbar. Migration per ADD COLUMN IF NOT EXISTS. - Produkttabelle: Spalte "Packungen" heisst jetzt "Einheiten (ist / soll)" und zeigt Bestand und Mindestbestand als "11 / 12 Glas"; die separate Spalte "Mindest" entfaellt. Das "niedrig"-Badge sitzt jetzt dort. - Die Bezeichnung wird ueberall verwendet (Chargenanzeige, Einheiten-Umschalter beim Bearbeiten, Mindestbestand-Auswahl, Umrechnungshinweis). - Speichern/Abbrechen im Chargen-Editor sind jetzt gleich breit (.btn-pair). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ def _ensure_schema() -> None:
|
|||||||
"REFERENCES units(id) ON DELETE SET NULL",
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||||||
"NOT NULL DEFAULT FALSE",
|
"NOT NULL DEFAULT FALSE",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
|
||||||
]
|
]
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ class Product(Base):
|
|||||||
)
|
)
|
||||||
# Number of base units contained in one package (e.g. 500 g per package).
|
# Number of base units contained in one package (e.g. 500 g per package).
|
||||||
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
|
||||||
|
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|
||||||
group_id: Mapped[int | None] = mapped_column(
|
group_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def create_product(
|
|||||||
base_unit=base_unit,
|
base_unit=base_unit,
|
||||||
display_unit_id=display_unit_id,
|
display_unit_id=display_unit_id,
|
||||||
package_size=payload.package_size,
|
package_size=payload.package_size,
|
||||||
|
package_label=payload.package_label or None,
|
||||||
group_id=payload.group_id,
|
group_id=payload.group_id,
|
||||||
min_stock=payload.min_stock,
|
min_stock=payload.min_stock,
|
||||||
min_stock_unit_id=payload.min_stock_unit_id,
|
min_stock_unit_id=payload.min_stock_unit_id,
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ class ProductBase(BaseModel):
|
|||||||
# Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit.
|
# Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit.
|
||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
|
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
||||||
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
||||||
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||||||
@@ -118,6 +120,7 @@ class ProductUpdate(BaseModel):
|
|||||||
base_unit: BaseUnit | None = None
|
base_unit: BaseUnit | None = None
|
||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0)
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
@@ -134,6 +137,7 @@ class ProductOut(BaseModel):
|
|||||||
base_unit: BaseUnit
|
base_unit: BaseUnit
|
||||||
display_unit_id: int | None = None
|
display_unit_id: int | None = None
|
||||||
package_size: float | None
|
package_size: float | None
|
||||||
|
package_label: str | None = None
|
||||||
group_id: int | None
|
group_id: int | None
|
||||||
min_stock: float | None
|
min_stock: float | None
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
|
|||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
barcode: "", name: "", brand: "", image_url: "",
|
barcode: "", name: "", brand: "", image_url: "",
|
||||||
unit_id: "", package_size: "", min_stock: "", group_id: "",
|
unit_id: "", package_size: "", package_label: "", min_stock: "", group_id: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Vorschläge für die Gebinde-Bezeichnung (frei überschreibbar).
|
||||||
|
const PACKAGE_LABELS = ["Packung", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"];
|
||||||
|
|
||||||
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
|
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
|
||||||
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
|
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ export default function ProductForm() {
|
|||||||
setForm({
|
setForm({
|
||||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||||
image_url: p.image_url || "", unit_id: uid,
|
image_url: p.image_url || "", unit_id: uid,
|
||||||
package_size: p.package_size ?? "",
|
package_size: p.package_size ?? "", package_label: p.package_label || "",
|
||||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||||
group_id: p.group_id ?? "",
|
group_id: p.group_id ?? "",
|
||||||
});
|
});
|
||||||
@@ -168,6 +171,7 @@ export default function ProductForm() {
|
|||||||
image_url: form.image_url || null,
|
image_url: form.image_url || null,
|
||||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||||
|
package_label: form.package_label.trim() || null,
|
||||||
min_stock: minBase,
|
min_stock: minBase,
|
||||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||||
min_stock_in_packages: sel === "package",
|
min_stock_in_packages: sel === "package",
|
||||||
@@ -271,6 +275,18 @@ export default function ProductForm() {
|
|||||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Bezeichnung der Einheit
|
||||||
|
<input list="package-labels" value={form.package_label}
|
||||||
|
onChange={(e) => set("package_label", e.target.value)} disabled={readOnly}
|
||||||
|
placeholder="Packung" />
|
||||||
|
<datalist id="package-labels">
|
||||||
|
{PACKAGE_LABELS.map((l) => <option key={l} value={l} />)}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<div className="grow" />
|
||||||
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Mindestbestand
|
Mindestbestand
|
||||||
@@ -284,7 +300,7 @@ export default function ProductForm() {
|
|||||||
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
{form.package_size && (
|
{form.package_size && (
|
||||||
<option value="package">
|
<option value="package">
|
||||||
Packung(en) à {fmt(form.package_size)} {baseShort}
|
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
@@ -342,11 +358,12 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
const unitFactor = product?.unit_factor || 1;
|
const unitFactor = product?.unit_factor || 1;
|
||||||
const unitName = product?.unit_name || baseShort;
|
const unitName = product?.unit_name || baseShort;
|
||||||
const pkgSize = product?.package_size || 0;
|
const pkgSize = product?.package_size || 0;
|
||||||
|
const pkgLabel = product?.package_label || "Packung";
|
||||||
|
|
||||||
// Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist –
|
// Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist –
|
||||||
// sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein.
|
// sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein.
|
||||||
const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor;
|
const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor;
|
||||||
const primaryLabel = pkgSize > 0 ? "Pkg" : unitName;
|
const primaryLabel = pkgSize > 0 ? pkgLabel : unitName;
|
||||||
|
|
||||||
function secondary(qty) {
|
function secondary(qty) {
|
||||||
if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`;
|
if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`;
|
||||||
@@ -434,7 +451,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
{pkgSize > 0 ? (
|
{pkgSize > 0 ? (
|
||||||
<select value={editUnit} onChange={(e) => changeEditUnit(e.target.value)}
|
<select value={editUnit} onChange={(e) => changeEditUnit(e.target.value)}
|
||||||
style={{ marginTop: 0 }}>
|
style={{ marginTop: 0 }}>
|
||||||
<option value="package">Packung(en)</option>
|
<option value="package">{pkgLabel}</option>
|
||||||
<option value="unit">{unitName}</option>
|
<option value="unit">{unitName}</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
@@ -466,7 +483,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
{isAdmin && (editing ? (
|
{isAdmin && (editing ? (
|
||||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
<div className="btn-pair">
|
||||||
<button className="btn sm primary" onClick={() => saveEdit(l)}>Speichern</button>
|
<button className="btn sm primary" onClick={() => saveEdit(l)}>Speichern</button>
|
||||||
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
|
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,7 +506,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{pkgSize > 0 && (
|
{pkgSize > 0 && (
|
||||||
<p className="muted small">1 Packung = {fmt(pkgSize)} {baseShort}</p>
|
<p className="muted small">1 {pkgLabel} = {fmt(pkgSize)} {baseShort}</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { useAuth } from "../auth";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { fmt, unitShort } from "../units";
|
import { fmt, unitShort } from "../units";
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
return p.package_size && p.package_size > 0
|
||||||
|
? (p.package_label || "Packung")
|
||||||
|
: (p.unit_name || unitShort(p.base_unit));
|
||||||
|
}
|
||||||
|
|
||||||
export default function Products() {
|
export default function Products() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
@@ -60,8 +70,7 @@ export default function Products() {
|
|||||||
<th>Marke</th>
|
<th>Marke</th>
|
||||||
<th>Einheit</th>
|
<th>Einheit</th>
|
||||||
<th className="num">Bestand</th>
|
<th className="num">Bestand</th>
|
||||||
<th className="num">Packungen</th>
|
<th className="num">Einheiten (ist / soll)</th>
|
||||||
<th className="num">Mindest</th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -86,28 +95,25 @@ export default function Products() {
|
|||||||
<td className="muted">{p.brand || "–"}</td>
|
<td className="muted">{p.brand || "–"}</td>
|
||||||
<td>
|
<td>
|
||||||
{p.unit_name || unitShort(p.base_unit)}
|
{p.unit_name || unitShort(p.base_unit)}
|
||||||
{p.package_size ? ` · Pkg ${fmt(p.package_size)} ${unitShort(p.base_unit)}` : ""}
|
{p.package_size
|
||||||
|
? ` · ${p.package_label || "Packung"} ${fmt(p.package_size)} ${unitShort(p.base_unit)}`
|
||||||
|
: ""}
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
{fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)}
|
{fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)}
|
||||||
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
{p.package_size
|
{fmt(p.stock / unitCount(p))}
|
||||||
? `${fmt(p.stock / p.package_size)} Pkg`
|
{p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "}
|
||||||
: `${fmt(p.stock / (p.unit_factor || 1))} ${p.unit_name || unitShort(p.base_unit)}`}
|
{countLabel(p)}
|
||||||
</td>
|
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
|
||||||
<td className="num muted">
|
|
||||||
{p.min_stock != null ? (
|
|
||||||
<>{fmt(p.min_stock_display)} {p.min_stock_unit_label}</>
|
|
||||||
) : "–"}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{products.length === 0 && (
|
{products.length === 0 && (
|
||||||
<tr><td colSpan={8} className="empty">Keine Produkte gefunden.</td></tr>
|
<tr><td colSpan={7} className="empty">Keine Produkte gefunden.</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ td .field-inline { margin-bottom: 0; flex-wrap: wrap; }
|
|||||||
Zahlenfelder duerfen wachsen, Einheiten-Dropdowns behalten ihre Textbreite. */
|
Zahlenfelder duerfen wachsen, Einheiten-Dropdowns behalten ihre Textbreite. */
|
||||||
.field-inline > input { flex: 1 1 auto; min-width: 78px; }
|
.field-inline > input { flex: 1 1 auto; min-width: 78px; }
|
||||||
.field-inline > select { flex: 0 0 auto; width: auto; min-width: 132px; }
|
.field-inline > select { flex: 0 0 auto; width: auto; min-width: 132px; }
|
||||||
|
/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */
|
||||||
|
.btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; }
|
||||||
|
|
||||||
/* ---------- Buttons ---------- */
|
/* ---------- Buttons ---------- */
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
Reference in New Issue
Block a user