Mehrere EAN-Codes je Produkt und je Gruppe (mit Notiz) + Gruppen umbenennen

Barcodes:
- Neue Tabelle barcodes (code, note, product_id ODER group_id). Damit lassen sich
  einem Produkt mehrere Codes geben und einer Gruppe beliebig viele Marken
  zuordnen (z.B. alle Mehlmarken zu "Mehl"). Je Code eine Notiz wie
  "Mehl bei Aldi".
- Lookup loest jetzt auch Alias-Codes auf; ist ein Code einer Gruppe zugeordnet,
  liefert die API group_id/group_name zurueck. Beim Anlegen aus einem Scan wird
  diese Gruppe automatisch gesetzt (Vorrang vor dem Kategorie-Rateversuch).
- Endpunkte: POST/DELETE /products/{id}/barcodes und /groups/{id}/barcodes.
- Web: wiederverwendbare BarcodeList-Komponente, eingebunden im Produktformular
  und auf der Gruppen-Seite.

Gruppen:
- Namen lassen sich jetzt direkt in der Tabelle aendern (PATCH war vorhanden,
  nur die Oberflaeche fehlte).

UI-Fix: Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der
Zellenbreite (Gruppen-/Benutzer-Dropdowns waren abgeschnitten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 14:48:14 +02:00
parent 8079d2d3f0
commit de6c8632a1
12 changed files with 341 additions and 48 deletions

View File

@@ -7,8 +7,8 @@ from datetime import date
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Lot, Product from .models import Barcode, Lot, Product
from .schemas import ProductOut from .schemas import BarcodeOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock from .services.stock import current_stock
@@ -26,6 +26,10 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
) )
.count() .count()
) )
out.barcodes = [
BarcodeOut.model_validate(b)
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
]
out.kind = KIND_OF_BASE[product.base_unit].value out.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product) name, factor = display_unit_info(product)
out.unit_name = name out.unit_name = name

View File

@@ -146,6 +146,28 @@ class Product(Base):
) )
class Barcode(Base):
"""Zusätzliche EAN-Codes für ein Produkt ODER eine Gruppe.
Beispiel Gruppe "Mehl": alle Mehl-Marken einscannen; ein Scan ordnet das
Produkt dann automatisch dieser Gruppe zu.
"""
__tablename__ = "barcodes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# Freitext zur Einordnung, z.B. "Mehl bei Aldi"
note: Mapped[str | None] = mapped_column(String(120), nullable=True)
product_id: Mapped[int | None] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=True
)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Lot(Base): class Lot(Base):
__tablename__ = "lots" __tablename__ = "lots"

View File

@@ -3,8 +3,8 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Group, Product, User from ..models import Barcode, Group, Product, User
from ..schemas import GroupCreate, GroupOut, GroupUpdate from ..schemas import BarcodeCreate, BarcodeOut, GroupCreate, GroupOut, GroupUpdate
from ..services.conversion import BASE_OF_KIND from ..services.conversion import BASE_OF_KIND
from ..services.stock import current_stock from ..services.stock import current_stock
@@ -15,6 +15,10 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group) out = GroupOut.model_validate(group)
products = db.query(Product).filter(Product.group_id == group.id).all() products = db.query(Product).filter(Product.group_id == group.id).all()
out.product_count = len(products) out.product_count = len(products)
out.barcodes = [
BarcodeOut.model_validate(b)
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all()
]
unit = group.min_stock_unit unit = group.min_stock_unit
if unit is not None: if unit is not None:
# Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt. # Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt.
@@ -82,6 +86,44 @@ def update_group(
return _group_to_out(db, group) return _group_to_out(db, group)
@router.post("/{group_id}/barcodes", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
def add_group_barcode(
group_id: int,
payload: BarcodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
"""EAN einer Gruppe zuordnen (z.B. alle Mehl-Marken zur Gruppe "Mehl")."""
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
code = payload.code.strip()
if db.query(Barcode).filter(Barcode.code == code).first() or (
db.query(Product).filter(Product.barcode == code).first()
):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben")
db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id))
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@router.delete("/{group_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group_barcode(
group_id: int,
code: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
entry = (
db.query(Barcode).filter(Barcode.group_id == group_id, Barcode.code == code).first()
)
if entry is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
db.delete(entry)
db.commit()
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group( def delete_group(
group_id: int, group_id: int,

View File

@@ -4,9 +4,9 @@ from sqlalchemy.orm import Session
from ..crud import product_to_out from ..crud import product_to_out
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import BaseUnit, Product, User from ..models import Barcode, BaseUnit, Group, Product, User
from ..off import lookup_barcode from ..off import lookup_barcode
from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
from ..services.conversion import ConversionError, resolve_product_unit from ..services.conversion import ConversionError, resolve_product_unit
router = APIRouter(prefix="/products", tags=["products"]) router = APIRouter(prefix="/products", tags=["products"])
@@ -32,15 +32,31 @@ def lookup(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
) -> LookupResult: ) -> LookupResult:
"""Barcode: erst lokal, dann Open Food Facts. Nur Vorschlag, legt nichts an.""" """Barcode auflösen: Produkt (Haupt- oder Alias-Code), Gruppen-Code, sonst OFF."""
existing = db.query(Product).filter(Product.barcode == barcode).first() code = barcode.strip()
existing = db.query(Product).filter(Product.barcode == code).first()
if existing is None:
alias = db.query(Barcode).filter(Barcode.code == code).first()
if alias is not None and alias.product_id:
existing = db.get(Product, alias.product_id)
if existing: if existing:
return LookupResult(found=True, existing_product=product_to_out(db, existing)) return LookupResult(found=True, existing_product=product_to_out(db, existing))
suggestion = lookup_barcode(barcode) # Code kann einer Gruppe zugeordnet sein (z.B. alle Mehl-Marken in "Mehl").
group_id = group_name = None
alias = db.query(Barcode).filter(Barcode.code == code).first()
if alias is not None and alias.group_id:
group = db.get(Group, alias.group_id)
if group is not None:
group_id, group_name = group.id, group.name
suggestion = lookup_barcode(code)
if suggestion: if suggestion:
return LookupResult(found=True, suggestion=suggestion) return LookupResult(
return LookupResult(found=False) found=True, suggestion=suggestion, group_id=group_id, group_name=group_name
)
return LookupResult(found=False, group_id=group_id, group_name=group_name)
@router.get("/{product_id}", response_model=ProductOut) @router.get("/{product_id}", response_model=ProductOut)
@@ -137,6 +153,46 @@ def update_product(
return product_to_out(db, product) return product_to_out(db, product)
@router.post("/{product_id}/barcodes", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def add_product_barcode(
product_id: int,
payload: BarcodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Weiteren EAN-Code zu einem Produkt hinzufügen."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
code = payload.code.strip()
if db.query(Barcode).filter(Barcode.code == code).first() or (
db.query(Product).filter(Product.barcode == code).first()
):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben")
db.add(Barcode(code=code, note=(payload.note or None), product_id=product.id))
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product_barcode(
product_id: int,
code: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
entry = (
db.query(Barcode)
.filter(Barcode.product_id == product_id, Barcode.code == code)
.first()
)
if entry is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
db.delete(entry)
db.commit()
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product( def delete_product(
product_id: int, product_id: int,

View File

@@ -23,6 +23,14 @@ class UnitCreate(BaseModel):
factor: float = Field(gt=0) factor: float = Field(gt=0)
# ---- Barcodes ----
class BarcodeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
note: str | None = None
# ---- Auth / Users ---- # ---- Auth / Users ----
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
@@ -62,6 +70,7 @@ class GroupOut(BaseModel):
stock: float = 0.0 # Bestand in Basiseinheiten stock: float = 0.0 # Bestand in Basiseinheiten
min_stock_unit_name: str | None = None min_stock_unit_name: str | None = None
kind: str | None = None # Art der Mindestbestand-Einheit kind: str | None = None # Art der Mindestbestand-Einheit
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
@@ -153,12 +162,22 @@ class ProductOut(BaseModel):
# Mindestbestand in der erfassten Einheit (für die Anzeige): # Mindestbestand in der erfassten Einheit (für die Anzeige):
min_stock_display: float | None = None min_stock_display: float | None = None
min_stock_unit_label: str = "" min_stock_unit_label: str = ""
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
barcodes: list[BarcodeOut] = []
class LookupResult(BaseModel): class LookupResult(BaseModel):
found: bool found: bool
existing_product: ProductOut | None = None existing_product: ProductOut | None = None
suggestion: dict | None = None suggestion: dict | None = None
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
group_id: int | None = None
group_name: str | None = None
class BarcodeCreate(BaseModel):
code: str = Field(min_length=4, max_length=64)
note: str | None = Field(default=None, max_length=120)
# ---- Stock movements ---- # ---- Stock movements ----

View File

@@ -104,6 +104,9 @@ export const api = {
createProduct: (body) => request("/products", { method: "POST", body }), createProduct: (body) => request("/products", { method: "POST", body }),
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }), updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }), deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
addProductBarcode: (id, body) => request(`/products/${id}/barcodes`, { method: "POST", body }),
deleteProductBarcode: (id, code) =>
request(`/products/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
// Bestand // Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
@@ -129,6 +132,9 @@ export const api = {
createGroup: (body) => request("/groups", { method: "POST", body }), createGroup: (body) => request("/groups", { method: "POST", body }),
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }), deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
addGroupBarcode: (id, body) => request(`/groups/${id}/barcodes`, { method: "POST", body }),
deleteGroupBarcode: (id, code) =>
request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
// Export / Import // Export / Import
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"), exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"),

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import Icon from "./Icon";
/**
* Liste zusätzlicher EAN-Codes mit Notiz ("Mehl bei Aldi").
* onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt.
*/
export default function BarcodeList({ barcodes = [], onAdd, onDelete, disabled = false, hint }) {
const [code, setCode] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
async function add(e) {
e.preventDefault();
if (!code.trim()) return;
setBusy(true);
try {
await onAdd({ code: code.trim(), note: note.trim() || null });
setCode("");
setNote("");
} finally {
setBusy(false);
}
}
return (
<div>
<ul className="simple-list">
{barcodes.map((b) => (
<li key={b.id ?? b.code}>
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<code className="strong">{b.code}</code>
{b.note && <span className="muted small">{b.note}</span>}
</span>
{!disabled && (
<button className="btn-icon danger" title="Code entfernen"
onClick={() => onDelete(b.code)}>
<Icon name="trash" size={16} />
</button>
)}
</li>
))}
{barcodes.length === 0 && <li className="muted small">Noch keine zusätzlichen Codes.</li>}
</ul>
{!disabled && (
<form className="field-inline" onSubmit={add} style={{ marginTop: "var(--sp-3)" }}>
<input placeholder="EAN scannen oder eingeben" value={code}
onChange={(e) => setCode(e.target.value)} />
<input placeholder="Notiz, z.B. Mehl bei Aldi" value={note}
onChange={(e) => setNote(e.target.value)} />
<button className="btn" disabled={busy || !code.trim()}>
<Icon name="plus" size={16} />Hinzufügen
</button>
</form>
)}
{hint && <p className="muted small">{hint}</p>}
</div>
);
}

View File

@@ -13,6 +13,8 @@ export default function CheckIn() {
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [suggestion, setSuggestion] = useState(null); const [suggestion, setSuggestion] = useState(null);
// 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 [unknownBarcode, setUnknownBarcode] = useState(null);
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -60,8 +62,10 @@ export default function CheckIn() {
setInfo(`Produkt erkannt: ${res.existing_product.name}`); setInfo(`Produkt erkannt: ${res.existing_product.name}`);
} else if (res.found && res.suggestion) { } else if (res.found && res.suggestion) {
setSuggestion(res.suggestion); setSuggestion(res.suggestion);
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
} else { } else {
setUnknownBarcode(barcode.trim()); setUnknownBarcode(barcode.trim());
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
} }
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -71,7 +75,9 @@ export default function CheckIn() {
async function createFromSuggestion() { async function createFromSuggestion() {
setError(null); setBusy(true); setError(null); setBusy(true);
try { try {
const payload = suggestionToProduct(suggestion, guessGroup(groups, suggestion)); // Ist der Code einer Gruppe zugeordnet, hat das Vorrang vor dem Kategorie-Rateversuch.
const groupId = suggestionGroup.id || guessGroup(groups, suggestion);
const payload = suggestionToProduct(suggestion, groupId);
const created = await api.createProduct(payload); const created = await api.createProduct(payload);
selectProduct(created); selectProduct(created);
setInfo(`Produkt "${created.name}" angelegt.`); setInfo(`Produkt "${created.name}" angelegt.`);
@@ -163,7 +169,10 @@ export default function CheckIn() {
{suggestion.brand ? `${suggestion.brand} · ` : ""}auf Open Food Facts gefunden {suggestion.brand ? `${suggestion.brand} · ` : ""}auf Open Food Facts gefunden
{suggestion.quantity_text ? ` · ${suggestion.quantity_text}` : ""} {suggestion.quantity_text ? ` · ${suggestion.quantity_text}` : ""}
</div> </div>
<div className="muted small">Noch nicht im Katalog.</div> <div className="muted small">
Noch nicht im Katalog.
{suggestionGroup.name && ` Gruppe: ${suggestionGroup.name}`}
</div>
</div> </div>
{isAdmin ? ( {isAdmin ? (
<button className="btn primary" onClick={createFromSuggestion} disabled={busy}> <button className="btn primary" onClick={createFromSuggestion} disabled={busy}>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { fmt } from "../units"; import { fmt } from "../units";
@@ -9,6 +10,7 @@ export default function Groups() {
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" }); const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
async function load() { async function load() {
@@ -40,6 +42,7 @@ export default function Groups() {
} }
async function patch(group, body) { async function patch(group, body) {
setError(null);
try { try {
await api.updateGroup(group.id, body); await api.updateGroup(group.id, body);
load(); load();
@@ -52,18 +55,23 @@ export default function Groups() {
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return; if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return;
try { try {
await api.deleteGroup(group.id); await api.deleteGroup(group.id);
if (selectedId === group.id) setSelectedId(null);
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
} }
const selected = groups.find((g) => g.id === selectedId) || null;
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Gruppen</h1> <h1>Gruppen</h1>
<div className="sub">Produkte zusammenfassen (z.B. Nudeln) und einen Gruppen-Mindestbestand mit Einheit setzen</div> <div className="sub">
Produkte zusammenfassen, Gruppen-Mindestbestand setzen und EAN-Codes hinterlegen
</div>
</div> </div>
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -79,6 +87,7 @@ export default function Groups() {
<th className="num">Bestand</th> <th className="num">Bestand</th>
<th>Mindestbestand</th> <th>Mindestbestand</th>
<th>Einheit</th> <th>Einheit</th>
<th className="num">EANs</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -87,8 +96,14 @@ export default function Groups() {
const low = g.min_stock != null && g.stock < g.min_stock; const low = g.min_stock != null && g.stock < g.min_stock;
return ( return (
<tr key={g.id}> <tr key={g.id}>
<td className="strong"> <td>
{g.name} {isAdmin ? (
<input defaultValue={g.name} style={{ marginTop: 0, minWidth: 130 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== g.name) patch(g, { name: v });
}} />
) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>} {low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td> </td>
<td className="num muted">{g.product_count}</td> <td className="num muted">{g.product_count}</td>
@@ -96,7 +111,7 @@ export default function Groups() {
<td> <td>
{isAdmin ? ( {isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""} <input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ maxWidth: 100, marginTop: 0 }} style={{ marginTop: 0, minWidth: 90 }}
onBlur={(e) => { onBlur={(e) => {
const v = e.target.value; const v = e.target.value;
if (v !== String(g.min_stock ?? "")) { if (v !== String(g.min_stock ?? "")) {
@@ -107,7 +122,7 @@ export default function Groups() {
</td> </td>
<td> <td>
{isAdmin ? ( {isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ maxWidth: 140, marginTop: 0 }} <select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, { onChange={(e) => patch(g, {
min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value), min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value),
})}> })}>
@@ -116,6 +131,11 @@ export default function Groups() {
</select> </select>
) : (g.min_stock_unit_name || "")} ) : (g.min_stock_unit_name || "")}
</td> </td>
<td className="num">
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
{g.barcodes?.length || 0} verwalten
</button>
</td>
<td className="num"> <td className="num">
{isAdmin && ( {isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen"> <button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
@@ -126,41 +146,71 @@ export default function Groups() {
</tr> </tr>
); );
})} })}
{groups.length === 0 && <tr><td colSpan={6} className="empty">Noch keine Gruppen.</td></tr>} {groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Gruppen.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{isAdmin && ( <div>
<form className="card" onSubmit={add}> {selected && (
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div> <section className="card">
<label> <div className="card-head">
Name <Icon name="search" />
<input placeholder="z.B. Nudeln" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /> <h2>EAN-Codes: {selected.name}</h2>
</label> </div>
<div className="row"> <BarcodeList
<label className="grow"> barcodes={selected.barcodes || []}
Mindestbestand (optional) disabled={!isAdmin}
<input type="number" step="any" value={form.min_stock} hint="Scannst du einen dieser Codes beim Einlagern, wird das neue Produkt automatisch dieser Gruppe zugeordnet."
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" /> onAdd={async (body) => {
try {
await api.addGroupBarcode(selected.id, body);
await load();
} catch (err) { setError(err.message); }
}}
onDelete={async (code) => {
try {
await api.deleteGroupBarcode(selected.id, code);
await load();
} catch (err) { setError(err.message); }
}}
/>
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
</section>
)}
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Mehl" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label> </label>
<label className="grow"> <div className="row">
Einheit <label className="grow">
<select value={form.min_stock_unit_id} Mindestbestand (optional)
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}> <input type="number" step="any" value={form.min_stock}
<option value=""> Basiseinheit </option> onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)} </label>
</select> <label className="grow">
</label> Einheit
</div> <select value={form.min_stock_unit_id}
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
<p className="muted small"> <option value=""> Basiseinheit </option>
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen {units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
(Gewicht/Volumen/Stück). Produkte ordnest du im Produkt-Formular einer Gruppe zu. </select>
</p> </label>
</form> </div>
)} <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen.
Namen lassen sich in der Tabelle direkt ändern.
</p>
</form>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup } from "../offUtils"; import { guessGroup } from "../offUtils";
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units"; import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
@@ -332,6 +333,28 @@ export default function ProductForm() {
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>} {readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form> </form>
{!isNew && product && (
<section className="card">
<div className="card-head"><Icon name="search" /><h2>Weitere EAN-Codes</h2></div>
<BarcodeList
barcodes={product.barcodes || []}
disabled={readOnly}
hint="Nützlich, wenn dasselbe Produkt mehrere Codes hat (z.B. Aktionspackung)."
onAdd={async (body) => {
try {
setProduct(await api.addProductBarcode(id, body));
} catch (err) { setError(err.message); }
}}
onDelete={async (code) => {
try {
await api.deleteProductBarcode(id, code);
setProduct(await api.getProduct(id));
} catch (err) { setError(err.message); }
}}
/>
</section>
)}
{!isNew && ( {!isNew && (
<LotsCard <LotsCard
product={product} product={product}

View File

@@ -76,7 +76,7 @@ export default function Users() {
</td> </td>
<td> <td>
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} <select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
disabled={u.id === me.id} style={{ maxWidth: 150 }}> disabled={u.id === me.id}>
<option value="user">Benutzer</option> <option value="user">Benutzer</option>
<option value="admin">Administrator</option> <option value="admin">Administrator</option>
</select> </select>

View File

@@ -160,6 +160,8 @@ 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; }
/* Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der Zelle. */
td select { width: auto; min-width: 132px; max-width: 100%; }
/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */ /* 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 { 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; } .btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; }