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 ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Group, User from ..models import Group, Product, User
from ..schemas import GroupCreate, GroupOut from ..schemas import GroupCreate, GroupOut, GroupUpdate
from ..services.stock import current_stock
router = APIRouter(prefix="/groups", tags=["groups"]) 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]) @router.get("", response_model=list[GroupOut])
def list_groups( def list_groups(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Group]: ) -> list[GroupOut]:
return db.query(Group).order_by(Group.name).all() 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) @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
@@ -21,14 +31,40 @@ def create_group(
payload: GroupCreate, payload: GroupCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> Group: ) -> GroupOut:
if db.query(Group).filter(Group.name == payload.name).first(): if db.query(Group).filter(Group.name == payload.name).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group(name=payload.name, min_stock=payload.min_stock) group = Group(name=payload.name, min_stock=payload.min_stock)
db.add(group) db.add(group)
db.commit() db.commit()
db.refresh(group) 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) @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 ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Lot, Product, User from ..models import Group, Lot, Movement, Product, User
from ..schemas import ExpiringItem, ShoppingItem from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
from ..services.stock import current_stock from ..services.stock import current_stock
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
@@ -41,6 +41,37 @@ def shopping_list(
return items 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]) @router.get("/expiring", response_model=list[ExpiringItem])
def expiring( def expiring(
days: int | None = None, days: int | None = None,
@@ -74,3 +105,40 @@ def expiring(
) )
) )
return result 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 id: int
name: str name: str
min_stock: float | None = None min_stock: float | None = None
# angereichert:
product_count: int = 0
stock: float = 0.0
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) 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 ---- # ---- Locations ----
@@ -169,6 +177,28 @@ class ExpiringItem(BaseModel):
days_left: int 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): class SettingOut(BaseModel):
key: str key: str
value: str value: str

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units"; import { fmt, unitShort } from "../units";
export default function Dashboard() { export default function Dashboard() {
const [expiring, setExpiring] = useState([]); const [expiring, setExpiring] = useState([]);
const [shopping, setShopping] = useState([]); const [shopping, setShopping] = useState([]);
const [groupShopping, setGroupShopping] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { 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); setExpiring(e);
setShopping(s); setShopping(s);
setGroupShopping(g);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@@ -21,61 +28,100 @@ export default function Dashboard() {
load(); load();
}, []); }, []);
const overdue = expiring.filter((e) => e.days_left < 0).length;
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
<div>
<h1>Übersicht</h1> <h1>Übersicht</h1>
<div className="sub">Bestand, Ablauf und Bedarf auf einen Blick</div>
</div>
<div className="actions"> <div className="actions">
<Link className="btn primary" to="/checkin">Einlagern</Link> <Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
<Link className="btn" to="/checkout">Auslagern</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>
</div> </div>
{error && <div className="alert error">{error}</div>}
<div className="grid-2"> <div className="grid-2">
<section className="card"> <section className="card">
<h2> Bald ablaufend</h2> <div className="card-head"><Icon name="clock" />
<h2>Bald ablaufend</h2>
</div>
{expiring.length === 0 ? ( {expiring.length === 0 ? (
<p className="muted">Nichts läuft demnächst ab. 🎉</p> <div className="empty">Nichts läuft demnächst ab.</div>
) : ( ) : (
<div className="table-wrap">
<table className="table"> <table className="table">
<thead> <thead>
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr> <tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
</thead> </thead>
<tbody> <tbody>
{expiring.map((it) => ( {expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}> <tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
<td>{it.product_name}</td> <td>{it.product_name}</td>
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td> <td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td> <td>{it.best_before}</td>
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td> <td className="num">
{it.days_left < 0
? <span className="badge danger">{-it.days_left}d überf.</span>
: it.days_left}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section> </section>
<section className="card"> <section className="card">
<h2>🛒 Einkaufsliste</h2> <div className="card-head"><Icon name="cart" />
{shopping.length === 0 ? ( <h2>Einkaufsliste</h2>
<p className="muted">Alle Mindestbestände erreicht. 👍</p> </div>
{shopping.length === 0 && groupShopping.length === 0 ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
) : ( ) : (
<div className="table-wrap">
<table className="table"> <table className="table">
<thead> <thead>
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr> <tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
</thead> </thead>
<tbody> <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) => ( {shopping.map((it) => (
<tr key={it.product_id}> <tr key={`p${it.product_id}`}>
<td>{it.name}</td> <td>{it.name}</td>
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td> <td className="num">{fmt(it.stock)} {unitShort(it.base_unit)}</td>
<td>{fmt(it.min_stock)}</td> <td className="num strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section> </section>
</div> </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 { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon";
export default function Locations() { export default function Locations() {
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [parentId, setParentId] = useState("");
const [error, setError] = useState(null); const [error, setError] = useState(null);
async function load() { async function load() {
@@ -20,8 +22,9 @@ export default function Locations() {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
try { try {
await api.createLocation({ name }); await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) });
setName(""); setName("");
setParentId("");
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -29,7 +32,7 @@ export default function Locations() {
} }
async function remove(id) { async function remove(id) {
if (!confirm("Lagerort löschen?")) return; if (!confirm("Lagerort löschen? Untergeordnete Orte werden dann übergeordnet.")) return;
try { try {
await api.deleteLocation(id); await api.deleteLocation(id);
load(); 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 ( return (
<div> <div>
<div className="page-head"><h1>Lagerorte</h1></div> <div className="page-head">
{error && <div className="alert error">{error}</div>} <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"> <div className="card form-narrow">
<form className="row" onSubmit={add}> <form onSubmit={add}>
<input className="grow" placeholder="Neuer Lagerort (z.B. Speisekammer)" value={name} <div className="row">
<label className="grow">
Name
<input placeholder="z.B. Speisekammer oder Regal 2" value={name}
onChange={(e) => setName(e.target.value)} required /> onChange={(e) => setName(e.target.value)} required />
<button className="btn primary">Hinzufügen</button> </label>
</form> <label className="grow">
<ul className="simple-list"> Übergeordneter Lagerort (optional)
<select value={parentId} onChange={(e) => setParentId(e.target.value)}>
<option value=""> keiner (oberste Ebene) </option>
{locations.map((l) => ( {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">
{ordered.map((l) => (
<li key={l.id}> <li key={l.id}>
<span>{l.name}</span> <span className={l.depth ? "tree-child" : ""} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn ghost danger" onClick={() => remove(l.id)}>Löschen</button> <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> </li>
))} ))}
{locations.length === 0 && <li className="muted">Noch keine Lagerorte.</li>} {locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>}
</ul> </ul>
</div> </div>
<p className="muted small">
Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt.
</p>
</div> </div>
); );
} }

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units"; import { fmt, unitShort } from "../units";
export default function Products() { export default function Products() {
@@ -31,51 +32,67 @@ export default function Products() {
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
<div>
<h1>Produkte</h1> <h1>Produkte</h1>
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>} <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> </div>
{error && <div className="alert error">{error}</div>}
<form className="search" onSubmit={onSearch}> {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<input placeholder="Suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn">Suchen</button> <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> </form>
<div className="card"> <div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Name</th> <th>Name</th>
<th>Marke</th> <th>Marke</th>
<th>Basiseinheit</th> <th>Einheit</th>
<th>Bestand</th> <th className="num">Bestand</th>
<th>Mindest</th> <th className="num">Mindest</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{products.map((p) => ( {products.map((p) => {
const low = p.min_stock != null && p.stock < p.min_stock;
return (
<tr key={p.id}> <tr key={p.id}>
<td className="thumb-cell"> <td className="thumb-cell">
{p.image_url ? <img className="thumb" src={p.image_url} alt="" /> : "📦"} {p.image_url
? <img className="thumb" src={p.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
</td> </td>
<td>{p.name}</td> <td className="strong">{p.name}</td>
<td className="muted">{p.brand || ""}</td> <td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""}</td> <td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""}</td>
<td className={p.min_stock != null && p.stock < p.min_stock ? "strong row-warn-text" : ""}> <td className="num">
{fmt(p.stock)} {unitShort(p.base_unit)} {fmt(p.stock)} {unitShort(p.base_unit)}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td> </td>
<td className="muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td> <td className="num muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td>
<td><Link to={`/products/${p.id}`}>Details</Link></td> <td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
</tr> </tr>
))} );
})}
{products.length === 0 && ( {products.length === 0 && (
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr> <tr><td colSpan={7} className="empty">Keine Produkte gefunden.</td></tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
</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 { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units"; import { fmt, unitShort } from "../units";
export default function ShoppingList() { export default function ShoppingList() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [groups, setGroups] = useState([]);
const [checked, setChecked] = useState({}); const [checked, setChecked] = useState({});
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { 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 ( return (
<div> <div>
<div className="page-head"><h1>🛒 Einkaufsliste</h1></div> <div className="page-head">
{error && <div className="alert error">{error}</div>} <div>
<p className="muted"> <h1>Einkaufsliste</h1>
Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt. <div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
</p> </div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card"> <div className="card">
{items.length === 0 ? ( {empty ? (
<p className="muted">Alle Mindestbestände erreicht. 👍</p> <div className="empty">Alle Mindestbestände erreicht.</div>
) : ( ) : (
<ul className="checklist"> <ul className="checklist">
{items.map((it) => ( {groups.map((it) => {
<li key={it.product_id} className={checked[it.product_id] ? "done" : ""}> const key = `g${it.group_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label> <label>
<input type="checkbox" checked={!!checked[it.product_id]} <input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} /> <span className="badge accent">Gruppe</span>
<span className="item-name">{it.name}</span> <span className="item-name">{it.name}</span>
</label> </label>
<span className="item-qty"> <span className="muted small">
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong> fehlt <strong>{fmt(it.deficit)}</strong> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
</span> </span>
</li> </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> </ul>
)} )}
</div> </div>
<p className="muted small"> <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> </p>
</div> </div>
); );

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 Icon from "../components/Icon";
export default function Users() { export default function Users() {
const { user: me } = useAuth(); const { user: me } = useAuth();
@@ -53,28 +54,38 @@ export default function Users() {
return ( return (
<div> <div>
<div className="page-head"><h1>Benutzer</h1></div> <div className="page-head">
{error && <div className="alert error">{error}</div>} <div>
{info && <div className="alert info">{info}</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="grid-2">
<div className="card"> <div className="card" style={{ padding: 0 }}>
<h2>Benutzer</h2> <div className="table-wrap">
<table className="table"> <table className="table">
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead> <thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
<tbody> <tbody>
{users.map((u) => ( {users.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td>{u.username}{u.id === me.id && <span className="muted"> (du)</span>}</td> <td className="strong">
<td> {u.username}{u.id === me.id && <span className="muted"> (du)</span>}
<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>
<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 && ( {u.id !== me.id && (
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button> <button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)} )}
</td> </td>
</tr> </tr>
@@ -82,9 +93,10 @@ export default function Users() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<form className="card form-narrow" onSubmit={add}> <form className="card" onSubmit={add}>
<h2>Neuer Benutzer</h2> <div className="card-head"><Icon name="users" /><h2>Neuer Benutzer</h2></div>
<label> <label>
Benutzername Benutzername
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required /> <input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
@@ -97,11 +109,11 @@ export default function Users() {
<label> <label>
Rolle Rolle
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}> <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="user">Nutzer (nur ein-/auslagern)</option> <option value="user">Benutzer (nur ein-/auslagern)</option>
<option value="admin">Admin (volle Verwaltung)</option> <option value="admin">Administrator (volle Verwaltung)</option>
</select> </select>
</label> </label>
<button className="btn primary">Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -1,185 +1,323 @@
:root { :root {
--bg: #f4f6f8; --bg: #f6f7f9;
--card: #ffffff; --surface: #ffffff;
--border: #e2e8f0; --surface-2: #f1f3f5;
--text: #1e293b; --border: #e3e6ea;
--muted: #64748b; --border-strong: #d1d6dc;
--primary: #2f855a; --text: #1a1f27;
--primary-dark: #276749; --muted: #6b7480;
--danger: #c53030; --accent: #3f51b5;
--warn: #b7791f; --accent-hover: #34429a;
--radius: 10px; --accent-soft: #eceefb;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08); --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; } * { box-sizing: border-box; }
html, body { height: 100%; }
body { body {
margin: 0; margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: var(--font);
background: var(--bg); background: var(--bg);
color: var(--text); 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 */ .icon { flex: 0 0 auto; vertical-align: middle; }
.topbar {
/* ---------- 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; display: flex;
align-items: center; flex-direction: column;
gap: 20px;
padding: 10px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; height: 100vh;
flex-wrap: wrap;
} }
.brand { font-weight: 700; font-size: 20px; } .sidebar-brand {
.brand.big { font-size: 30px; } display: flex;
.nav { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; } 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 { .nav-link {
padding: 7px 12px; display: flex; align-items: center; gap: var(--sp-3);
border-radius: 8px; padding: 9px var(--sp-3);
text-decoration: none; border-radius: var(--radius-sm);
color: var(--muted); color: var(--muted);
font-weight: 500; font-weight: 520;
font-size: 0.9rem;
} }
.nav-link:hover { background: var(--bg); } .nav-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.nav-link.active { background: var(--primary); color: #fff; } .nav-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.userbox { display: flex; align-items: center; gap: 10px; } .nav-section {
.username { font-size: 14px; color: var(--muted); } padding: var(--sp-4) var(--sp-3) var(--sp-1);
.badge { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em;
background: var(--primary); color: #fff; font-size: 11px; text-transform: uppercase; color: var(--muted); opacity: 0.7;
padding: 1px 6px; border-radius: 6px; font-weight: 700;
} }
.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 { .page-head {
display: flex; align-items: center; justify-content: space-between; 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; } .page-head .sub { color: var(--muted); font-size: 0.9rem; margin-top: 2px; }
.actions { display: flex; gap: 8px; } .actions { display: flex; gap: var(--sp-2); }
/* Cards & layout */ /* ---------- Cards ---------- */
.card { .card {
background: var(--card); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 18px; padding: var(--sp-5);
box-shadow: var(--shadow); box-shadow: var(--shadow);
margin-bottom: 16px; margin-bottom: var(--sp-4);
} }
.card h2 { margin-top: 0; font-size: 17px; } .card-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .card-head h2 { margin: 0; }
.form-narrow { max-width: 560px; } .card-head .icon { color: var(--muted); }
@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } } .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 */ /* ---------- Forms ---------- */
label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); font-weight: 600; } label { display: block; margin-bottom: var(--sp-4); font-size: 0.8rem; color: var(--muted); font-weight: 580; }
input, select { input, select {
width: 100%; width: 100%; margin-top: 5px;
margin-top: 4px; padding: 8px 10px;
padding: 9px 10px; border: 1px solid var(--border-strong);
border: 1px solid var(--border); border-radius: var(--radius-sm);
border-radius: 8px; font-size: 0.9rem; font-family: inherit;
font-size: 15px; background: var(--surface); color: var(--text);
background: #fff; transition: border-color .12s, box-shadow .12s;
color: var(--text);
} }
input:focus, select:focus { outline: 2px solid var(--primary); border-color: var(--primary); } input:focus, select:focus {
.row { display: flex; gap: 12px; align-items: flex-start; } 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; } .row .grow { flex: 1; }
.search { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; } .field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; }
.search input { margin-top: 0; }
/* Buttons */ /* ---------- Buttons ---------- */
.btn { .btn {
display: inline-block; display: inline-flex; align-items: center; gap: var(--sp-2);
padding: 9px 16px; padding: 8px 14px;
border: 1px solid var(--border); border: 1px solid var(--border-strong);
background: #fff; background: var(--surface);
border-radius: 8px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 0.875rem; font-weight: 560; font-family: inherit;
font-weight: 600; cursor: pointer; color: var(--text);
cursor: pointer; transition: background .12s, border-color .12s, opacity .12s;
text-decoration: none; white-space: nowrap;
color: var(--text);
} }
.btn:hover { background: var(--bg); } .btn:hover { background: var(--surface-2); text-decoration: none; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); } .btn.primary:hover { background: var(--accent-hover); }
.btn.danger { color: var(--danger); border-color: var(--danger); } .btn.danger { color: var(--danger); border-color: transparent; background: transparent; }
.btn.ghost { border-color: transparent; background: transparent; } .btn.danger:hover { background: var(--danger-soft); }
.btn:disabled { opacity: 0.6; cursor: default; } .btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
.link-btn { background: none; border: none; color: var(--primary); cursor: pointer; font-size: 15px; padding: 4px 0; text-align: left; } .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 */ /* ---------- Tables ---------- */
.table { width: 100%; border-collapse: collapse; font-size: 14px; } .table-wrap { overflow-x: auto; }
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); } .table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; } .table th, .table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); }
.row-danger { background: #fff5f5; } .table thead th {
.row-warn { background: #fffaf0; } color: var(--muted); font-weight: 600; font-size: 0.72rem;
.row-warn-text { color: var(--warn); } text-transform: uppercase; letter-spacing: 0.04em;
.strong { font-weight: 700; } border-bottom: 1px solid var(--border-strong);
.thumb-cell { width: 40px; } }
.thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; } .table tbody tr:last-child td { border-bottom: none; }
.product-img { max-width: 160px; border-radius: 8px; margin-bottom: 10px; } .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 */ /* ---------- Badges / pills ---------- */
.picklist, .simple-list, .checklist { list-style: none; padding: 0; margin: 10px 0 0; } .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 { .simple-list li, .checklist li {
display: flex; align-items: center; justify-content: space-between; 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 li.done .item-name { text-decoration: line-through; color: var(--muted); }
.checklist label { display: flex; align-items: center; gap: 8px; margin: 0; } .item-name { font-weight: 560; }
.checklist input[type="checkbox"] { width: auto; margin: 0; }
.item-name { font-weight: 600; color: var(--text); }
/* Alerts */ /* ---------- Alerts ---------- */
.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; } .alert {
.alert.error { background: #fff5f5; color: var(--danger); border: 1px solid #feb2b2; } display: flex; align-items: flex-start; gap: var(--sp-2);
.alert.info { background: #ebf8ff; color: #2b6cb0; border: 1px solid #bee3f8; } padding: 10px 14px; border-radius: var(--radius-sm); margin-bottom: var(--sp-4);
.alert.warn { background: #fffaf0; color: var(--warn); border: 1px solid #fbd38d; } font-size: 0.875rem; border: 1px solid transparent;
/* Selected product banner */
.selected-product {
display: flex; align-items: center; gap: 12px;
padding: 10px; background: var(--bg); border-radius: 8px; margin-bottom: 16px;
} }
.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 */ /* ---------- Selected product banner ---------- */
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .selected-product {
.login-card { width: 100%; max-width: 360px; text-align: center; } display: flex; align-items: center; gap: var(--sp-3);
.login-card label { text-align: left; } 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); } .muted { color: var(--muted); }
.small { font-size: 13px; } .small { font-size: 0.8rem; }
.center { text-align: center; padding: 40px; } .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) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0f172a; --bg: #0d1117;
--card: #1e293b; --surface: #161b22;
--border: #334155; --surface-2: #1c2230;
--text: #e2e8f0; --border: #262d38;
--muted: #94a3b8; --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; } .badge.ok { background: #14301f; }
.btn { background: #0f172a; } .alert.ok { background: #14301f; border-color: #21503a; }
.btn:hover { background: #172033; } }
.row-danger { background: #3b1a1a; } :root[data-theme="dark"] {
.row-warn { background: #3a2f14; } --bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38;
.alert.error { background: #3b1a1a; border-color: #7f1d1d; } --border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
.alert.info { background: #16324d; border-color: #1e4e79; } --accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
.alert.warn { background: #3a2f14; border-color: #7c5c14; } --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;
} }