Web-UI: professionelles Redesign (Icons statt Emojis) + neue Features

Design:
- Neues Icon-Set (Inline-SVG, Feather-Stil), keine Emojis mehr
- Kohaerentes Design-System (Sidebar-Layout, Palette, Spacing, Light/Dark)
- Alle Seiten ueberarbeitet (Login, Dashboard, Produkte, Formular, Ein-/Auslagern,
  Lagerorte, Benutzer, Einkaufsliste)

Neue Features:
- Gruppen-Verwaltung (anlegen/bearbeiten/loeschen, Produktzahl + Bestand,
  Gruppen-Mindestbestand) inkl. Einkaufsliste
- Einfache Gruppen-Auto-Zuordnung aus OFF-Kategorie im Produktformular
- Verlauf-Seite (Bewegungen: wer/was/wann) via neuem /movements-Endpoint
- Einstellungen-Seite (Ablauf-Warnfrist)
- Lagerorte mit optional uebergeordnetem Ort (verschachtelbar)

Backend:
- groups: PATCH + Anreicherung (product_count, stock)
- views: /shopping-list/groups, /movements
- schemas: GroupUpdate, GroupShoppingItem, MovementOut

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 10:18:30 +02:00
parent 9d4fd030d8
commit b683f2d93b
19 changed files with 1304 additions and 421 deletions

View File

@@ -3,17 +3,27 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Group, User
from ..schemas import GroupCreate, GroupOut
from ..models import Group, Product, User
from ..schemas import GroupCreate, GroupOut, GroupUpdate
from ..services.stock import current_stock
router = APIRouter(prefix="/groups", tags=["groups"])
def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group)
products = db.query(Product).filter(Product.group_id == group.id).all()
out.product_count = len(products)
out.stock = float(sum(current_stock(db, p.id) for p in products))
return out
@router.get("", response_model=list[GroupOut])
def list_groups(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Group]:
return db.query(Group).order_by(Group.name).all()
) -> list[GroupOut]:
groups = db.query(Group).order_by(Group.name).all()
return [_group_to_out(db, g) for g in groups]
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
@@ -21,14 +31,40 @@ def create_group(
payload: GroupCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Group:
) -> GroupOut:
if db.query(Group).filter(Group.name == payload.name).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group(name=payload.name, min_stock=payload.min_stock)
db.add(group)
db.commit()
db.refresh(group)
return group
return _group_to_out(db, group)
@router.patch("/{group_id}", response_model=GroupOut)
def update_group(
group_id: int,
payload: GroupUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "name" in data and data["name"]:
clash = (
db.query(Group)
.filter(Group.name == data["name"], Group.id != group_id)
.first()
)
if clash:
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
for field, value in data.items():
setattr(group, field, value)
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -5,8 +5,8 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Lot, Product, User
from ..schemas import ExpiringItem, ShoppingItem
from ..models import Group, Lot, Movement, Product, User
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
from ..services.stock import current_stock
from .settings import get_expiry_warning_days
@@ -41,6 +41,37 @@ def shopping_list(
return items
@router.get("/shopping-list/groups", response_model=list[GroupShoppingItem])
def group_shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[GroupShoppingItem]:
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt.
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
"""
items: list[GroupShoppingItem] = []
groups = (
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
)
for group in groups:
products = db.query(Product).filter(Product.group_id == group.id).all()
stock = float(sum(current_stock(db, p.id) for p in products))
if stock < group.min_stock:
items.append(
GroupShoppingItem(
group_id=group.id,
name=group.name,
stock=stock,
min_stock=group.min_stock,
deficit=group.min_stock - stock,
product_count=len(products),
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
@router.get("/expiring", response_model=list[ExpiringItem])
def expiring(
days: int | None = None,
@@ -74,3 +105,40 @@ def expiring(
)
)
return result
@router.get("/movements", response_model=list[MovementOut])
def movements(
limit: int = 100,
product_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MovementOut]:
"""Bewegungsverlauf (wer hat wann was ein-/ausgelagert), neueste zuerst."""
limit = max(1, min(limit, 500))
query = (
db.query(Movement, Product, User)
.join(Product, Movement.product_id == Product.id)
.outerjoin(User, Movement.user_id == User.id)
)
if product_id is not None:
query = query.filter(Movement.product_id == product_id)
rows = query.order_by(Movement.created_at.desc(), Movement.id.desc()).limit(limit).all()
result: list[MovementOut] = []
for movement, product, user in rows:
result.append(
MovementOut(
id=movement.id,
product_id=product.id,
product_name=product.name,
type=movement.type.value,
quantity=movement.quantity,
base_unit=product.base_unit,
unit_used=movement.unit_used,
username=user.username if user else None,
note=movement.note,
created_at=movement.created_at,
)
)
return result

View File

@@ -40,11 +40,19 @@ class GroupOut(BaseModel):
id: int
name: str
min_stock: float | None = None
# angereichert:
product_count: int = 0
stock: float = 0.0
class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
min_stock: float | None = None
min_stock: float | None = Field(default=None, ge=0)
class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0)
# ---- Locations ----
@@ -169,6 +177,28 @@ class ExpiringItem(BaseModel):
days_left: int
class GroupShoppingItem(BaseModel):
group_id: int
name: str
stock: float
min_stock: float
deficit: float
product_count: int
class MovementOut(BaseModel):
id: int
product_id: int
product_name: str
type: str
quantity: float
base_unit: BaseUnit
unit_used: str
username: str | None
note: str | None
created_at: datetime
class SettingOut(BaseModel):
key: str
value: str

View File

@@ -1,16 +1,31 @@
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { useAuth } from "./auth";
import Icon from "./components/Icon";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
import ProductForm from "./pages/ProductForm";
import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups";
import Locations from "./pages/Locations";
import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList";
import History from "./pages/History";
import Settings from "./pages/Settings";
function Layout({ children }) {
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
function NavItem({ to, icon, label, end }) {
return (
<NavLink to={to} end={end} className={navClass}>
<Icon name={icon} size={17} />
<span className="label">{label}</span>
</NavLink>
);
}
function Sidebar() {
const { user, isAdmin, logout } = useAuth();
const navigate = useNavigate();
@@ -19,39 +34,55 @@ function Layout({ children }) {
navigate("/login");
}
const link = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
return (
<div className="app">
<header className="topbar">
<div className="brand">🥫 Project-Good</div>
<nav className="nav">
<NavLink to="/" end className={link}>Übersicht</NavLink>
<NavLink to="/checkin" className={link}>Einlagern</NavLink>
<NavLink to="/checkout" className={link}>Auslagern</NavLink>
<NavLink to="/products" className={link}>Produkte</NavLink>
<NavLink to="/shopping" className={link}>Einkaufsliste</NavLink>
{isAdmin && <NavLink to="/locations" className={link}>Lagerorte</NavLink>}
{isAdmin && <NavLink to="/users" className={link}>Benutzer</NavLink>}
</nav>
<div className="userbox">
<span className="username">
{user?.username} {isAdmin && <span className="badge">Admin</span>}
</span>
<button className="btn ghost" onClick={handleLogout}>Abmelden</button>
<aside className="sidebar">
<div className="sidebar-brand">
<span className="mark"><Icon name="box" size={18} /></span>
Project-Good
</div>
<nav className="sidebar-nav">
<NavItem to="/" end icon="dashboard" label="Übersicht" />
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" />
<NavItem to="/groups" icon="tag" label="Gruppen" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && (
<>
<div className="nav-section">Verwaltung</div>
<NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/settings" icon="settings" label="Einstellungen" />
</>
)}
</nav>
<div className="sidebar-foot">
<div className="sidebar-user">
<span className="name">{user?.username}</span>
<span className="role">{isAdmin ? "Administrator" : "Benutzer"}</span>
</div>
</header>
<main className="content">{children}</main>
</div>
<button className="btn-icon" onClick={handleLogout} title="Abmelden">
<Icon name="logout" size={18} />
</button>
</div>
</aside>
);
}
function Protected({ children, adminOnly = false }) {
const { user, isAdmin, loading } = useAuth();
if (loading) return <div className="center muted">Lädt</div>;
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt</div>;
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
return <Layout>{children}</Layout>;
return (
<div className="app-shell">
<Sidebar />
<div className="main">
<div className="content">{children}</div>
</div>
</div>
);
}
export default function App() {
@@ -69,9 +100,12 @@ export default function App() {
<Route path="/products" element={<Protected><Products /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
<Route path="/groups" element={<Protected><Groups /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);

View File

@@ -90,7 +90,9 @@ export const api = {
// Views
shoppingList: () => request("/shopping-list"),
groupShoppingList: () => request("/shopping-list/groups"),
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
// Stammdaten
listLocations: () => request("/locations"),
@@ -99,6 +101,8 @@ export const api = {
listGroups: () => request("/groups"),
createGroup: (body) => request("/groups", { method: "POST", body }),
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
// Benutzer
listUsers: () => request("/users"),

144
web/src/components/Icon.jsx Normal file
View File

@@ -0,0 +1,144 @@
// Schlankes Inline-SVG-Icon-Set (Feather-Stil). Bewusst keine Emojis.
// Verwendung: <Icon name="package" /> oder <Icon name="trash" size={16} />
const PATHS = {
dashboard: (
<>
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</>
),
checkin: (
<>
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
</>
),
checkout: (
<>
<path d="M12 21V9" />
<path d="m7 14 5-5 5 5" />
<path d="M5 3h14" />
</>
),
package: (
<>
<path d="m7.5 4.27 9 5.15" />
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</>
),
tag: (
<>
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
</>
),
location: (
<>
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</>
),
cart: (
<>
<circle cx="8" cy="21" r="1" />
<circle cx="19" cy="21" r="1" />
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" />
</>
),
users: (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
history: (
<>
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l3 2" />
</>
),
settings: (
<>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</>
),
logout: (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</>
),
plus: <path d="M5 12h14M12 5v14" />,
trash: (
<>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</>
),
edit: (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</>
),
alert: (
<>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4M12 17h.01" />
</>
),
check: <path d="M20 6 9 17l-5-5" />,
chevronRight: <path d="m9 18 6-6-6-6" />,
box: (
<>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
</>
),
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
const path = PATHS[name];
if (!path) return null;
return (
<svg
className={`icon ${className}`}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{path}
</svg>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { fmt, unitOptions, unitShort } from "../units";
export default function CheckIn() {
@@ -30,8 +31,7 @@ export default function CheckIn() {
setProduct(p);
setResults([]);
setUnknownBarcode(null);
const opts = unitOptions(p);
setUnit(opts[0].value);
setUnit(unitOptions(p)[0].value);
}
async function doLookup() {
@@ -44,7 +44,6 @@ export default function CheckIn() {
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
} else {
setUnknownBarcode(barcode.trim());
setInfo(null);
}
} catch (err) {
setError(err.message);
@@ -84,36 +83,40 @@ export default function CheckIn() {
return (
<div>
<div className="page-head"><h1>📥 Einlagern</h1></div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
<div className="page-head"><h1>Einlagern</h1></div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
{!product && (
<div className="grid-2">
<div className="card">
<h2>Per Barcode</h2>
<div className="row">
<input className="grow" placeholder="Barcode" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
<div className="card-head"><Icon name="search" /><h2>Per Barcode</h2></div>
<div className="field-inline">
<label className="grow" style={{ margin: 0 }}>
<input placeholder="Barcode eingeben oder scannen" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} autoFocus />
</label>
<button className="btn primary" onClick={doLookup}>Suchen</button>
</div>
{unknownBarcode && (
<div className="alert warn">
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
{isAdmin ? (
<Link to={`/products/new`}>Produkt anlegen</Link>
) : (
"Bitte einen Administrator bitten, das Produkt anzulegen."
)}
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
{isAdmin
? <Link to="/products/new">Produkt anlegen</Link>
: "Bitte einen Administrator bitten, das Produkt anzulegen."}
</span>
</div>
)}
</div>
<div className="card">
<h2>Aus Produktliste</h2>
<form className="row" onSubmit={doSearch}>
<input className="grow" placeholder="Name suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} />
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
<form className="field-inline" onSubmit={doSearch}>
<label className="grow" style={{ margin: 0 }}>
<input placeholder="Name suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
</label>
<button className="btn">Suchen</button>
</form>
<ul className="picklist">
@@ -132,12 +135,12 @@ export default function CheckIn() {
{product && (
<form className="card form-narrow" onSubmit={submit}>
<div className="selected-product">
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
<div>
<strong>{product.name}</strong>
<div className="muted">
Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}
</div>
{product.image_url
? <img className="thumb" src={product.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info">
<div className="title">{product.name}</div>
<div className="muted small">Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
@@ -151,15 +154,13 @@ export default function CheckIn() {
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<div className="row">
<label className="grow">
MHD (optional)
Mindesthaltbarkeit (optional)
<input type="date" value={bestBefore} onChange={(e) => setBestBefore(e.target.value)} />
</label>
<label className="grow">
@@ -170,7 +171,7 @@ export default function CheckIn() {
</select>
</label>
</div>
<button className="btn primary" disabled={busy}>{busy ? "…" : "Einlagern"}</button>
<button className="btn primary" disabled={busy}><Icon name="checkin" size={16} />{busy ? "…" : "Einlagern"}</button>
</form>
)}
</div>

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitOptions, unitShort } from "../units";
export default function CheckOut() {
@@ -54,7 +55,7 @@ export default function CheckOut() {
quantity: Number(quantity),
unit,
});
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n) betroffen). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setQuantity("");
setProduct(await api.getProduct(product.id));
} catch (err) {
@@ -66,26 +67,29 @@ export default function CheckOut() {
return (
<div>
<div className="page-head"><h1>📤 Auslagern</h1></div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
<div className="page-head"><h1>Auslagern</h1></div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
{!product && (
<div className="grid-2">
<div className="card">
<h2>Per Barcode</h2>
<div className="row">
<input className="grow" placeholder="Barcode" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
<div className="card-head"><Icon name="search" /><h2>Per Barcode</h2></div>
<div className="field-inline">
<label className="grow" style={{ margin: 0 }}>
<input placeholder="Barcode eingeben oder scannen" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} autoFocus />
</label>
<button className="btn primary" onClick={doLookup}>Suchen</button>
</div>
</div>
<div className="card">
<h2>Aus Produktliste</h2>
<form className="row" onSubmit={doSearch}>
<input className="grow" placeholder="Name suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} />
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
<form className="field-inline" onSubmit={doSearch}>
<label className="grow" style={{ margin: 0 }}>
<input placeholder="Name suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
</label>
<button className="btn">Suchen</button>
</form>
<ul className="picklist">
@@ -104,12 +108,12 @@ export default function CheckOut() {
{product && (
<form className="card form-narrow" onSubmit={submit}>
<div className="selected-product">
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
<div>
<strong>{product.name}</strong>
<div className="muted">
Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}
</div>
{product.image_url
? <img className="thumb" src={product.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info">
<div className="title">{product.name}</div>
<div className="muted small">Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
@@ -123,16 +127,14 @@ export default function CheckOut() {
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<p className="muted small">
<p className="muted small mt-0">
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
</p>
<button className="btn primary" disabled={busy}>{busy ? "…" : "Auslagern"}</button>
<button className="btn primary" disabled={busy}><Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}</button>
</form>
)}
</div>

View File

@@ -1,19 +1,26 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units";
export default function Dashboard() {
const [expiring, setExpiring] = useState([]);
const [shopping, setShopping] = useState([]);
const [groupShopping, setGroupShopping] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
async function load() {
try {
const [e, s] = await Promise.all([api.expiring(), api.shoppingList()]);
const [e, s, g] = await Promise.all([
api.expiring(),
api.shoppingList(),
api.groupShoppingList(),
]);
setExpiring(e);
setShopping(s);
setGroupShopping(g);
} catch (err) {
setError(err.message);
}
@@ -21,61 +28,100 @@ export default function Dashboard() {
load();
}, []);
const overdue = expiring.filter((e) => e.days_left < 0).length;
return (
<div>
<div className="page-head">
<h1>Übersicht</h1>
<div>
<h1>Übersicht</h1>
<div className="sub">Bestand, Ablauf und Bedarf auf einen Blick</div>
</div>
<div className="actions">
<Link className="btn primary" to="/checkin">Einlagern</Link>
<Link className="btn" to="/checkout">Auslagern</Link>
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="stat-row">
<div className="stat">
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
<div className="stat-value">{expiring.length}</div>
</div>
<div className="stat">
<div className="stat-label"><Icon name="alert" size={14} />Überfällig</div>
<div className="stat-value">{overdue}</div>
</div>
<div className="stat">
<div className="stat-label"><Icon name="cart" size={14} />Nachzukaufen</div>
<div className="stat-value">{shopping.length + groupShopping.length}</div>
</div>
</div>
{error && <div className="alert error">{error}</div>}
<div className="grid-2">
<section className="card">
<h2> Bald ablaufend</h2>
<div className="card-head"><Icon name="clock" />
<h2>Bald ablaufend</h2>
</div>
{expiring.length === 0 ? (
<p className="muted">Nichts läuft demnächst ab. 🎉</p>
<div className="empty">Nichts läuft demnächst ab.</div>
) : (
<table className="table">
<thead>
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr>
</thead>
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
<td>{it.product_name}</td>
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td>
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td>
</tr>
))}
</tbody>
</table>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
</thead>
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
<td>{it.product_name}</td>
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td>
<td className="num">
{it.days_left < 0
? <span className="badge danger">{-it.days_left}d überf.</span>
: it.days_left}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="card">
<h2>🛒 Einkaufsliste</h2>
{shopping.length === 0 ? (
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
<div className="card-head"><Icon name="cart" />
<h2>Einkaufsliste</h2>
</div>
{shopping.length === 0 && groupShopping.length === 0 ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
) : (
<table className="table">
<thead>
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr>
</thead>
<tbody>
{shopping.map((it) => (
<tr key={it.product_id}>
<td>{it.name}</td>
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td>
<td>{fmt(it.min_stock)}</td>
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
</tr>
))}
</tbody>
</table>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
</thead>
<tbody>
{groupShopping.map((it) => (
<tr key={`g${it.group_id}`}>
<td><span className="badge accent">Gruppe</span> {it.name}</td>
<td className="num">{fmt(it.stock)}</td>
<td className="num strong">{fmt(it.deficit)}</td>
</tr>
))}
{shopping.map((it) => (
<tr key={`p${it.product_id}`}>
<td>{it.name}</td>
<td className="num">{fmt(it.stock)} {unitShort(it.base_unit)}</td>
<td className="num strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>

133
web/src/pages/Groups.jsx Normal file
View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { fmt } from "../units";
export default function Groups() {
const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]);
const [name, setName] = useState("");
const [minStock, setMinStock] = useState("");
const [error, setError] = useState(null);
async function load() {
try {
setGroups(await api.listGroups());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) });
setName("");
setMinStock("");
load();
} catch (err) {
setError(err.message);
}
}
async function saveMin(group, value) {
try {
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) });
load();
} catch (err) {
setError(err.message);
}
}
async function remove(group) {
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return;
try {
await api.deleteGroup(group.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div>
<div className="page-head">
<div>
<h1>Gruppen</h1>
<div className="sub">Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand</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>Gruppe</th><th className="num">Produkte</th><th className="num">Bestand</th><th>Mindestbestand</th><th></th></tr>
</thead>
<tbody>
{groups.map((g) => {
const low = g.min_stock != null && g.stock < g.min_stock;
return (
<tr key={g.id}>
<td className="strong">
{g.name}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num muted">{g.product_count}</td>
<td className="num">{fmt(g.stock)}</td>
<td>
{isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ maxWidth: 110, marginTop: 0 }}
onBlur={(e) => {
const v = e.target.value;
if (v !== String(g.min_stock ?? "")) saveMin(g, v);
}} />
) : (g.min_stock != null ? fmt(g.min_stock) : "")}
</td>
<td className="num">
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
);
})}
{groups.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Gruppen.</td></tr>}
</tbody>
</table>
</div>
</div>
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>
Mindestbestand (optional)
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)}
placeholder="Gesamtmenge über alle Produkte der Gruppe" />
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu.
Der Gruppen-Bestand ist die Summe der Produktbestände sinnvoll, wenn
die Produkte dieselbe Basiseinheit haben.
</p>
</form>
)}
</div>
</div>
);
}

62
web/src/pages/History.jsx Normal file
View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units";
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
export default function History() {
const [movements, setMovements] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
api.listMovements(150).then(setMovements).catch((e) => setError(e.message));
}, []);
return (
<div>
<div className="page-head">
<div>
<h1>Verlauf</h1>
<div className="sub">Wer hat wann was ein- und ausgelagert</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Aktion</th>
<th>Produkt</th>
<th className="num">Menge</th>
<th>Benutzer</th>
</tr>
</thead>
<tbody>
{movements.map((m) => (
<tr key={m.id}>
<td className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
<td>
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
<Icon name={m.type === "in" ? "checkin" : m.type === "out" ? "checkout" : "edit"} size={13} />
{TYPE_LABEL[m.type] || m.type}
</span>
</td>
<td>{m.product_name}</td>
<td className="num">{fmt(m.quantity)} {unitShort(m.base_unit)}</td>
<td className="muted">{m.username || ""}</td>
</tr>
))}
{movements.length === 0 && (
<tr><td colSpan={5} className="empty">Noch keine Bewegungen.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
export default function Locations() {
const [locations, setLocations] = useState([]);
const [name, setName] = useState("");
const [parentId, setParentId] = useState("");
const [error, setError] = useState(null);
async function load() {
@@ -20,8 +22,9 @@ export default function Locations() {
e.preventDefault();
setError(null);
try {
await api.createLocation({ name });
await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) });
setName("");
setParentId("");
load();
} catch (err) {
setError(err.message);
@@ -29,7 +32,7 @@ export default function Locations() {
}
async function remove(id) {
if (!confirm("Lagerort löschen?")) return;
if (!confirm("Lagerort löschen? Untergeordnete Orte werden dann übergeordnet.")) return;
try {
await api.deleteLocation(id);
load();
@@ -38,29 +41,66 @@ export default function Locations() {
}
}
const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name]));
const roots = locations.filter((l) => !l.parent_id || !nameById[l.parent_id]);
const childrenOf = (pid) => locations.filter((l) => l.parent_id === pid);
const ordered = [];
for (const r of roots) {
ordered.push({ ...r, depth: 0 });
for (const c of childrenOf(r.id)) ordered.push({ ...c, depth: 1 });
}
return (
<div>
<div className="page-head"><h1>Lagerorte</h1></div>
{error && <div className="alert error">{error}</div>}
<div className="page-head">
<div>
<h1>Lagerorte</h1>
<div className="sub">Orte lassen sich verschachteln (z.B. Keller Regal 2 Fach A)</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card form-narrow">
<form className="row" onSubmit={add}>
<input className="grow" placeholder="Neuer Lagerort (z.B. Speisekammer)" value={name}
onChange={(e) => setName(e.target.value)} required />
<button className="btn primary">Hinzufügen</button>
<form onSubmit={add}>
<div className="row">
<label className="grow">
Name
<input placeholder="z.B. Speisekammer oder Regal 2" value={name}
onChange={(e) => setName(e.target.value)} required />
</label>
<label className="grow">
Übergeordneter Lagerort (optional)
<select value={parentId} onChange={(e) => setParentId(e.target.value)}>
<option value=""> keiner (oberste Ebene) </option>
{locations.map((l) => (
<option key={l.id} value={l.id}>
{l.parent_id && nameById[l.parent_id] ? `${nameById[l.parent_id]}` : ""}{l.name}
</option>
))}
</select>
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
</form>
<ul className="simple-list">
{locations.map((l) => (
{ordered.map((l) => (
<li key={l.id}>
<span>{l.name}</span>
<button className="btn ghost danger" onClick={() => remove(l.id)}>Löschen</button>
<span className={l.depth ? "tree-child" : ""} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Icon name="location" size={15} className="muted" />
{l.name}
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (
<span className="badge">in {nameById[l.parent_id]}</span>
)}
</span>
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen">
<Icon name="trash" size={16} />
</button>
</li>
))}
{locations.length === 0 && <li className="muted">Noch keine Lagerorte.</li>}
{locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>}
</ul>
</div>
<p className="muted small">
Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt.
</p>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
export default function Login() {
const { login } = useAuth();
@@ -27,9 +28,14 @@ export default function Login() {
return (
<div className="login-wrap">
<form className="card login-card" onSubmit={onSubmit}>
<div className="brand big">🥫 Project-Good</div>
<p className="muted">Lebensmittel-Lagerverwaltung</p>
{error && <div className="alert error">{error}</div>}
<div className="sidebar-brand">
<span className="mark"><Icon name="box" size={18} /></span>
Project-Good
</div>
<p className="lead">Lebensmittel-Lagerverwaltung</p>
{error && (
<div className="alert error"><Icon name="alert" size={16} />{error}</div>
)}
<label>
Benutzername
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
@@ -38,7 +44,7 @@ export default function Login() {
Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</label>
<button className="btn primary" disabled={busy}>
<button className="btn primary" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
{busy ? "Anmelden…" : "Anmelden"}
</button>
</form>

View File

@@ -2,19 +2,28 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { BASE_UNITS, fmt, unitShort } from "../units";
const EMPTY = {
barcode: "",
name: "",
brand: "",
image_url: "",
base_unit: "piece",
package_size: "",
min_stock: "",
group_id: "",
barcode: "", name: "", brand: "", image_url: "",
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
};
// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten.
function guessGroup(groups, suggestion) {
if (!suggestion) return "";
const haystack = [
suggestion.category_suggestion || "",
...(suggestion.category_tags || []),
suggestion.name || "",
]
.join(" ")
.toLowerCase();
const hit = groups.find((g) => haystack.includes(g.name.toLowerCase()));
return hit ? String(hit.id) : "";
}
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
@@ -37,13 +46,9 @@ export default function ProductForm() {
const p = await api.getProduct(id);
setProduct(p);
setForm({
barcode: p.barcode || "",
name: p.name,
brand: p.brand || "",
image_url: p.image_url || "",
base_unit: p.base_unit,
package_size: p.package_size ?? "",
min_stock: p.min_stock ?? "",
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
image_url: p.image_url || "", base_unit: p.base_unit,
package_size: p.package_size ?? "", min_stock: p.min_stock ?? "",
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
@@ -71,14 +76,19 @@ export default function ProductForm() {
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
const s = res.suggestion;
const groupGuess = guessGroup(groups, s);
setForm((f) => ({
...f,
name: s.name || f.name,
brand: s.brand || f.brand,
image_url: s.image_url || f.image_url,
base_unit: s.base_unit || f.base_unit,
group_id: f.group_id || groupGuess,
}));
setInfo("Vorschlag von Open Food Facts übernommen.");
setInfo(
"Daten von Open Food Facts übernommen." +
(groupGuess ? " Passende Gruppe vorgeschlagen." : "")
);
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
@@ -135,21 +145,26 @@ export default function ProductForm() {
return (
<div>
<div className="page-head">
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
<div>
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)}</div>}
</div>
<button className="btn ghost" onClick={() => navigate("/products")}>Zurück</button>
</div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{info && <div className="alert info"><Icon name="check" size={16} />{info}</div>}
<div className="grid-2">
<form className="card" onSubmit={save}>
<div className="row">
<div className="field-inline">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={lookup} style={{ alignSelf: "flex-end" }}>
Nachschlagen
<button type="button" className="btn" onClick={lookup}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
</div>
@@ -165,21 +180,18 @@ export default function ProductForm() {
<label className="grow">
Basiseinheit
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
{BASE_UNITS.map((u) => (
<option key={u.value} value={u.value}>{u.label}</option>
))}
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)}
</select>
</label>
<label className="grow">
Packungsgröße (in Basiseinheit)
Packungsgröße
<input type="number" step="any" value={form.package_size}
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>
</div>
<div className="row">
<label className="grow">
Mindestbestand (in Basiseinheit)
Mindestbestand
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} />
</label>
@@ -187,39 +199,43 @@ export default function ProductForm() {
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>
))}
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</label>
</div>
{isAdmin && (
<div className="row">
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
{!isNew && <button type="button" className="btn danger" onClick={remove}>Löschen</button>}
{!isNew && (
<button type="button" className="btn danger" onClick={remove}>
<Icon name="trash" size={16} />Löschen
</button>
)}
</div>
)}
{readOnly && <p className="muted">Nur Administratoren können Produkte bearbeiten.</p>}
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && (
<section className="card">
<h2>Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})</h2>
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
<table className="table">
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="muted">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
<div className="table-wrap">
<table className="table">
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td className="num">{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
</div>
</section>
)}
</div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units";
export default function Products() {
@@ -31,50 +32,66 @@ export default function Products() {
return (
<div>
<div className="page-head">
<h1>Produkte</h1>
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>}
<div>
<h1>Produkte</h1>
<div className="sub">{products.length} Produkte im Katalog</div>
</div>
{isAdmin && (
<Link className="btn primary" to="/products/new"><Icon name="plus" size={16} />Neues Produkt</Link>
)}
</div>
{error && <div className="alert error">{error}</div>}
<form className="search" onSubmit={onSearch}>
<input placeholder="Suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn">Suchen</button>
{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}>
<label className="grow" style={{ margin: 0 }}>
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
<button className="btn"><Icon name="search" size={16} />Suchen</button>
</form>
<div className="card">
<table className="table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Marke</th>
<th>Basiseinheit</th>
<th>Bestand</th>
<th>Mindest</th>
<th></th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id}>
<td className="thumb-cell">
{p.image_url ? <img className="thumb" src={p.image_url} alt="" /> : "📦"}
</td>
<td>{p.name}</td>
<td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""}</td>
<td className={p.min_stock != null && p.stock < p.min_stock ? "strong row-warn-text" : ""}>
{fmt(p.stock)} {unitShort(p.base_unit)}
</td>
<td className="muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td>
<td><Link to={`/products/${p.id}`}>Details</Link></td>
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Marke</th>
<th>Einheit</th>
<th className="num">Bestand</th>
<th className="num">Mindest</th>
<th></th>
</tr>
))}
{products.length === 0 && (
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr>
)}
</tbody>
</table>
</thead>
<tbody>
{products.map((p) => {
const low = p.min_stock != null && p.stock < p.min_stock;
return (
<tr key={p.id}>
<td className="thumb-cell">
{p.image_url
? <img className="thumb" src={p.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
</td>
<td className="strong">{p.name}</td>
<td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""}</td>
<td className="num">
{fmt(p.stock)} {unitShort(p.base_unit)}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td>
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
</tr>
);
})}
{products.length === 0 && (
<tr><td colSpan={7} className="empty">Keine Produkte gefunden.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
const EXPIRY_KEY = "expiry_warning_days";
export default function Settings() {
const [days, setDays] = useState("");
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
async function load() {
try {
const settings = await api.listSettings();
const row = settings.find((s) => s.key === EXPIRY_KEY);
setDays(row ? row.value : "7");
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function save(e) {
e.preventDefault();
setError(null); setInfo(null); setBusy(true);
try {
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
setInfo("Gespeichert.");
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<div>
<div className="page-head">
<div>
<h1>Einstellungen</h1>
<div className="sub">Allgemeine Konfiguration der Anwendung</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
<form className="card form-narrow" onSubmit={save}>
<div className="card-head"><Icon name="clock" /><h2>Ablaufwarnung</h2></div>
<label>
Vorwarnzeit (Tage)
<input type="number" min="0" step="1" value={days}
onChange={(e) => setDays(e.target.value)} style={{ maxWidth: 160 }} />
</label>
<p className="muted small mt-0">
Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder
bereits überschritten ist), erscheinen auf der Übersicht unter Bald ablaufend".
</p>
<button className="btn primary" disabled={busy}>{busy ? "Speichern" : "Speichern"}</button>
</form>
</div>
);
}

View File

@@ -1,46 +1,76 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units";
export default function ShoppingList() {
const [items, setItems] = useState([]);
const [groups, setGroups] = useState([]);
const [checked, setChecked] = useState({});
const [error, setError] = useState(null);
useEffect(() => {
api.shoppingList().then(setItems).catch((e) => setError(e.message));
Promise.all([api.shoppingList(), api.groupShoppingList()])
.then(([p, g]) => { setItems(p); setGroups(g); })
.catch((e) => setError(e.message));
}, []);
const empty = items.length === 0 && groups.length === 0;
function toggle(key, val) {
setChecked((c) => ({ ...c, [key]: val }));
}
return (
<div>
<div className="page-head"><h1>🛒 Einkaufsliste</h1></div>
{error && <div className="alert error">{error}</div>}
<p className="muted">
Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt.
</p>
<div className="page-head">
<div>
<h1>Einkaufsliste</h1>
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card">
{items.length === 0 ? (
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
{empty ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
) : (
<ul className="checklist">
{items.map((it) => (
<li key={it.product_id} className={checked[it.product_id] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[it.product_id]}
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} />
<span className="item-name">{it.name}</span>
</label>
<span className="item-qty">
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
</span>
</li>
))}
{groups.map((it) => {
const key = `g${it.group_id}`;
return (
<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">Gruppe</span>
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
fehlt <strong>{fmt(it.deficit)}</strong> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
</span>
</li>
);
})}
{items.map((it) => {
const key = `p${it.product_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
</span>
</li>
);
})}
</ul>
)}
</div>
<p className="muted small">
Das Abhaken dient hier nur der Übersicht beim Einkaufen und wird (noch) nicht gespeichert.
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
</p>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
export default function Users() {
const { user: me } = useAuth();
@@ -53,38 +54,49 @@ export default function Users() {
return (
<div>
<div className="page-head"><h1>Benutzer</h1></div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
<div className="page-head">
<div>
<h1>Benutzer</h1>
<div className="sub">Administratoren verwalten alles, Benutzer lagern nur ein und aus</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
<div className="grid-2">
<div className="card">
<h2>Benutzer</h2>
<table className="table">
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.username}{u.id === me.id && <span className="muted"> (du)</span>}</td>
<td>
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} disabled={u.id === me.id}>
<option value="user">Nutzer</option>
<option value="admin">Admin</option>
</select>
</td>
<td>
{u.id !== me.id && (
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="strong">
{u.username}{u.id === me.id && <span className="muted"> (du)</span>}
</td>
<td>
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
disabled={u.id === me.id} style={{ maxWidth: 150 }}>
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
</select>
</td>
<td className="num">
{u.id !== me.id && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<form className="card form-narrow" onSubmit={add}>
<h2>Neuer Benutzer</h2>
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="users" /><h2>Neuer Benutzer</h2></div>
<label>
Benutzername
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
@@ -97,11 +109,11 @@ export default function Users() {
<label>
Rolle
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="user">Nutzer (nur ein-/auslagern)</option>
<option value="admin">Admin (volle Verwaltung)</option>
<option value="user">Benutzer (nur ein-/auslagern)</option>
<option value="admin">Administrator (volle Verwaltung)</option>
</select>
</label>
<button className="btn primary">Anlegen</button>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form>
</div>
</div>

View File

@@ -1,185 +1,323 @@
:root {
--bg: #f4f6f8;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--primary: #2f855a;
--primary-dark: #276749;
--danger: #c53030;
--warn: #b7791f;
--radius: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f1f3f5;
--border: #e3e6ea;
--border-strong: #d1d6dc;
--text: #1a1f27;
--muted: #6b7480;
--accent: #3f51b5;
--accent-hover: #34429a;
--accent-soft: #eceefb;
--danger: #c0392b;
--danger-soft: #fbeceb;
--warn: #b06f14;
--warn-soft: #fbf3e6;
--ok: #2e7d55;
--radius: 7px;
--radius-sm: 5px;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sidebar-w: 236px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 15px;
font-size: 14.5px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.app { min-height: 100vh; }
h1 { font-size: 1.35rem; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spacing: -0.005em; }
/* Topbar */
.topbar {
.icon { flex: 0 0 auto; vertical-align: middle; }
/* ---------- App shell ---------- */
.app-shell { display: flex; min-height: 100vh; }
.sidebar {
width: var(--sidebar-w);
flex: 0 0 var(--sidebar-w);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
align-items: center;
gap: 20px;
padding: 10px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
flex-direction: column;
position: sticky;
top: 0;
z-index: 10;
flex-wrap: wrap;
height: 100vh;
}
.brand { font-weight: 700; font-size: 20px; }
.brand.big { font-size: 30px; }
.nav { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-5) var(--sp-4) var(--sp-4);
font-weight: 680;
font-size: 1.05rem;
letter-spacing: -0.01em;
}
.sidebar-brand .mark {
display: grid; place-items: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
background: var(--accent); color: #fff;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; padding: var(--sp-2); flex: 1; overflow-y: auto; }
.nav-link {
padding: 7px 12px;
border-radius: 8px;
text-decoration: none;
display: flex; align-items: center; gap: var(--sp-3);
padding: 9px var(--sp-3);
border-radius: var(--radius-sm);
color: var(--muted);
font-weight: 500;
font-weight: 520;
font-size: 0.9rem;
}
.nav-link:hover { background: var(--bg); }
.nav-link.active { background: var(--primary); color: #fff; }
.userbox { display: flex; align-items: center; gap: 10px; }
.username { font-size: 14px; color: var(--muted); }
.badge {
background: var(--primary); color: #fff; font-size: 11px;
padding: 1px 6px; border-radius: 6px; font-weight: 700;
.nav-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.nav-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.nav-section {
padding: var(--sp-4) var(--sp-3) var(--sp-1);
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--muted); opacity: 0.7;
}
.sidebar-foot {
border-top: 1px solid var(--border);
padding: var(--sp-3);
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
}
.sidebar-user { display: flex; flex-direction: column; min-width: 0; }
.sidebar-user .name { font-weight: 600; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user .role { font-size: 0.72rem; color: var(--muted); }
.content { max-width: 1000px; margin: 0 auto; padding: 24px 20px 60px; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.content { width: 100%; max-width: 1080px; margin: 0 auto; padding: var(--sp-6) var(--sp-5) 64px; }
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px; gap: 12px; flex-wrap: wrap;
gap: var(--sp-3); margin-bottom: var(--sp-5); flex-wrap: wrap;
}
.page-head h1 { margin: 0; font-size: 24px; }
.actions { display: flex; gap: 8px; }
.page-head .sub { color: var(--muted); font-size: 0.9rem; margin-top: 2px; }
.actions { display: flex; gap: var(--sp-2); }
/* Cards & layout */
/* ---------- Cards ---------- */
.card {
background: var(--card);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
padding: var(--sp-5);
box-shadow: var(--shadow);
margin-bottom: 16px;
margin-bottom: var(--sp-4);
}
.card h2 { margin-top: 0; font-size: 17px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-narrow { max-width: 560px; }
@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } }
.card-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4); }
.card-head h2 { margin: 0; }
.card-head .icon { color: var(--muted); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
.form-narrow { max-width: 600px; }
@media (max-width: 820px) { .grid-2 { grid-template-columns: 1fr; } }
/* Forms */
label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); font-weight: 600; }
/* ---------- Forms ---------- */
label { display: block; margin-bottom: var(--sp-4); font-size: 0.8rem; color: var(--muted); font-weight: 580; }
input, select {
width: 100%;
margin-top: 4px;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 15px;
background: #fff;
color: var(--text);
width: 100%; margin-top: 5px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 0.9rem; font-family: inherit;
background: var(--surface); color: var(--text);
transition: border-color .12s, box-shadow .12s;
}
input:focus, select:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
.row { display: flex; gap: 12px; align-items: flex-start; }
input:focus, select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
input::placeholder { color: var(--muted); opacity: 0.7; }
.row { display: flex; gap: var(--sp-3); align-items: flex-start; }
.row .grow { flex: 1; }
.search { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; }
.search input { margin-top: 0; }
.field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; }
/* Buttons */
/* ---------- Buttons ---------- */
.btn {
display: inline-block;
padding: 9px 16px;
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
color: var(--text);
display: inline-flex; align-items: center; gap: var(--sp-2);
padding: 8px 14px;
border: 1px solid var(--border-strong);
background: var(--surface);
border-radius: var(--radius-sm);
font-size: 0.875rem; font-weight: 560; font-family: inherit;
cursor: pointer; color: var(--text);
transition: background .12s, border-color .12s, opacity .12s;
white-space: nowrap;
}
.btn:hover { background: var(--bg); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); }
.btn.danger { color: var(--danger); border-color: var(--danger); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn:disabled { opacity: 0.6; cursor: default; }
.link-btn { background: none; border: none; color: var(--primary); cursor: pointer; font-size: 15px; padding: 4px 0; text-align: left; }
.btn:hover { background: var(--surface-2); text-decoration: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--accent-hover); }
.btn.danger { color: var(--danger); border-color: transparent; background: transparent; }
.btn.danger:hover { background: var(--danger-soft); }
.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
.btn.ghost:hover { background: var(--surface-2); color: var(--text); }
.btn.sm { padding: 5px 9px; font-size: 0.8rem; }
.btn:disabled { opacity: 0.55; cursor: default; }
.btn-icon { padding: 6px; border-color: transparent; background: transparent; color: var(--muted); }
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
.btn-icon.danger:hover { background: var(--danger-soft); color: var(--danger); }
.link-btn { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; padding: 0; text-align: left; }
.link-btn:hover { text-decoration: underline; }
/* Tables */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
.row-danger { background: #fff5f5; }
.row-warn { background: #fffaf0; }
.row-warn-text { color: var(--warn); }
.strong { font-weight: 700; }
.thumb-cell { width: 40px; }
.thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }
.product-img { max-width: 160px; border-radius: 8px; margin-bottom: 10px; }
/* ---------- Tables ---------- */
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.table th, .table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); }
.table thead th {
color: var(--muted); font-weight: 600; font-size: 0.72rem;
text-transform: uppercase; letter-spacing: 0.04em;
border-bottom: 1px solid var(--border-strong);
}
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover { background: var(--surface-2); }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.strong { font-weight: 640; }
.thumb-cell { width: 44px; }
.thumb {
width: 34px; height: 34px; object-fit: cover;
border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2);
}
.thumb-fallback { display: grid; place-items: center; color: var(--muted); }
.product-img { max-width: 150px; border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: var(--sp-3); }
/* Lists */
.picklist, .simple-list, .checklist { list-style: none; padding: 0; margin: 10px 0 0; }
/* ---------- Badges / pills ---------- */
.badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.7rem; font-weight: 600; padding: 2px 7px; border-radius: 20px;
background: var(--surface-2); color: var(--muted);
}
.badge.accent { background: var(--accent-soft); color: var(--accent); }
.badge.danger { background: var(--danger-soft); color: var(--danger); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.ok { background: #e6f4ec; color: var(--ok); }
/* ---------- Lists ---------- */
.simple-list, .picklist, .checklist { list-style: none; padding: 0; margin: var(--sp-3) 0 0; }
.simple-list li, .checklist li {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 4px; border-bottom: 1px solid var(--border); gap: 10px;
padding: 10px 4px; border-bottom: 1px solid var(--border); gap: var(--sp-3);
}
.picklist li { padding: 6px 0; border-bottom: 1px solid var(--border); }
.simple-list li:last-child, .checklist li:last-child { border-bottom: none; }
.picklist li { padding: 0; border-bottom: 1px solid var(--border); }
.picklist li:last-child { border-bottom: none; }
.picklist .link-btn { display: block; width: 100%; padding: 9px 4px; }
.picklist .link-btn:hover { text-decoration: none; background: var(--surface-2); }
.checklist label { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-size: inherit; color: var(--text); }
.checklist input[type="checkbox"] { width: auto; margin: 0; accent-color: var(--accent); }
.checklist li.done .item-name { text-decoration: line-through; color: var(--muted); }
.checklist label { display: flex; align-items: center; gap: 8px; margin: 0; }
.checklist input[type="checkbox"] { width: auto; margin: 0; }
.item-name { font-weight: 600; color: var(--text); }
.item-name { font-weight: 560; }
/* Alerts */
.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
.alert.error { background: #fff5f5; color: var(--danger); border: 1px solid #feb2b2; }
.alert.info { background: #ebf8ff; color: #2b6cb0; border: 1px solid #bee3f8; }
.alert.warn { background: #fffaf0; color: var(--warn); border: 1px solid #fbd38d; }
/* Selected product banner */
.selected-product {
display: flex; align-items: center; gap: 12px;
padding: 10px; background: var(--bg); border-radius: 8px; margin-bottom: 16px;
/* ---------- Alerts ---------- */
.alert {
display: flex; align-items: flex-start; gap: var(--sp-2);
padding: 10px 14px; border-radius: var(--radius-sm); margin-bottom: var(--sp-4);
font-size: 0.875rem; border: 1px solid transparent;
}
.selected-product > div { flex: 1; }
.alert .icon { margin-top: 1px; }
.alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; }
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #cfd6f4; }
.alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; }
.alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; }
/* Login */
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.login-card { width: 100%; max-width: 360px; text-align: center; }
.login-card label { text-align: left; }
/* ---------- Selected product banner ---------- */
.selected-product {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3); background: var(--surface-2);
border: 1px solid var(--border); border-radius: var(--radius-sm); margin-bottom: var(--sp-4);
}
.selected-product > .info { flex: 1; min-width: 0; }
.selected-product .title { font-weight: 620; }
/* ---------- Stat tiles ---------- */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); }
.stat {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: var(--sp-4); box-shadow: var(--shadow);
}
.stat .stat-label { font-size: 0.75rem; color: var(--muted); font-weight: 560; display: flex; align-items: center; gap: 6px; }
.stat .stat-value { font-size: 1.6rem; font-weight: 680; letter-spacing: -0.02em; margin-top: 6px; }
/* ---------- Login ---------- */
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.login-card { width: 100%; max-width: 360px; }
.login-card .sidebar-brand { padding: 0 0 var(--sp-2); font-size: 1.2rem; }
.login-card .lead { color: var(--muted); font-size: 0.9rem; margin: 0 0 var(--sp-5); }
/* ---------- Utilities ---------- */
.muted { color: var(--muted); }
.small { font-size: 13px; }
.center { text-align: center; padding: 40px; }
.small { font-size: 0.8rem; }
.center { text-align: center; }
.empty { text-align: center; color: var(--muted); padding: var(--sp-6) var(--sp-4); }
.mt-0 { margin-top: 0; }
.tree-child { padding-left: var(--sp-5); }
.row-danger td { background: var(--danger-soft); }
.row-warn td { background: var(--warn-soft); }
/* ---------- Responsive: sidebar -> top bar ---------- */
@media (max-width: 760px) {
.app-shell { flex-direction: column; }
.sidebar {
width: 100%; flex-basis: auto; height: auto; position: sticky; top: 0; z-index: 20;
border-right: none; border-bottom: 1px solid var(--border);
}
.sidebar-brand { padding: var(--sp-3) var(--sp-4); }
.sidebar-nav { flex-direction: row; overflow-x: auto; padding: var(--sp-2) var(--sp-3); gap: var(--sp-1); }
.nav-section { display: none; }
.nav-link { white-space: nowrap; }
.nav-link span.label { display: none; }
.nav-link { padding: 8px; }
.content { padding: var(--sp-4); }
}
/* ---------- Dark theme ---------- */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--bg: #0d1117;
--surface: #161b22;
--surface-2: #1c2230;
--border: #262d38;
--border-strong: #333c4a;
--text: #e6edf3;
--muted: #8b949e;
--accent: #7c8ae8;
--accent-hover: #93a0ef;
--accent-soft: #1e2340;
--danger: #f0857a;
--danger-soft: #2a1a1a;
--warn: #e0a955;
--warn-soft: #2a2312;
--ok: #5fbf8c;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
input, select { background: #0f172a; }
.btn { background: #0f172a; }
.btn:hover { background: #172033; }
.row-danger { background: #3b1a1a; }
.row-warn { background: #3a2f14; }
.alert.error { background: #3b1a1a; border-color: #7f1d1d; }
.alert.info { background: #16324d; border-color: #1e4e79; }
.alert.warn { background: #3a2f14; border-color: #7c5c14; }
.badge.ok { background: #14301f; }
.alert.ok { background: #14301f; border-color: #21503a; }
}
:root[data-theme="dark"] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38;
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
}
:root[data-theme="light"] {
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5;
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
}