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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
</nav>
|
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||||
<div className="userbox">
|
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||||
<span className="username">
|
{isAdmin && (
|
||||||
{user?.username} {isAdmin && <span className="badge">Admin</span>}
|
<>
|
||||||
</span>
|
<div className="nav-section">Verwaltung</div>
|
||||||
<button className="btn ghost" onClick={handleLogout}>Abmelden</button>
|
<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>
|
</div>
|
||||||
</header>
|
<button className="btn-icon" onClick={handleLogout} title="Abmelden">
|
||||||
<main className="content">{children}</main>
|
<Icon name="logout" size={18} />
|
||||||
</div>
|
</button>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
144
web/src/components/Icon.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
onChange={(e) => setBarcode(e.target.value)}
|
<input placeholder="Barcode eingeben oder scannen" value={barcode}
|
||||||
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
|
onChange={(e) => setBarcode(e.target.value)}
|
||||||
|
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)" }}>
|
||||||
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
|
<Icon name="alert" size={16} />
|
||||||
{isAdmin ? (
|
<span>
|
||||||
<Link to={`/products/new`}>Produkt anlegen</Link>
|
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
|
||||||
) : (
|
{isAdmin
|
||||||
"Bitte einen Administrator bitten, das Produkt anzulegen."
|
? <Link to="/products/new">Produkt anlegen</Link>
|
||||||
)}
|
: "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>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
onChange={(e) => setBarcode(e.target.value)}
|
<input placeholder="Barcode eingeben oder scannen" value={barcode}
|
||||||
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
|
onChange={(e) => setBarcode(e.target.value)}
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<h1>Übersicht</h1>
|
<div>
|
||||||
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<table className="table">
|
<div className="table-wrap">
|
||||||
<thead>
|
<table className="table">
|
||||||
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{expiring.map((it) => (
|
<tbody>
|
||||||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
|
{expiring.map((it) => (
|
||||||
<td>{it.product_name}</td>
|
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
|
||||||
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
<td>{it.product_name}</td>
|
||||||
<td>{it.best_before}</td>
|
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
||||||
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td>
|
<td>{it.best_before}</td>
|
||||||
</tr>
|
<td className="num">
|
||||||
))}
|
{it.days_left < 0
|
||||||
</tbody>
|
? <span className="badge danger">{-it.days_left}d überf.</span>
|
||||||
</table>
|
: it.days_left}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<table className="table">
|
<div className="table-wrap">
|
||||||
<thead>
|
<table className="table">
|
||||||
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{shopping.map((it) => (
|
<tbody>
|
||||||
<tr key={it.product_id}>
|
{groupShopping.map((it) => (
|
||||||
<td>{it.name}</td>
|
<tr key={`g${it.group_id}`}>
|
||||||
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td>
|
<td><span className="badge accent">Gruppe</span> {it.name}</td>
|
||||||
<td>{fmt(it.min_stock)}</td>
|
<td className="num">{fmt(it.stock)}</td>
|
||||||
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
|
<td className="num strong">{fmt(it.deficit)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
{shopping.map((it) => (
|
||||||
</table>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
133
web/src/pages/Groups.jsx
Normal file
133
web/src/pages/Groups.jsx
Normal 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
62
web/src/pages/History.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
onChange={(e) => setName(e.target.value)} required />
|
<label className="grow">
|
||||||
<button className="btn primary">Hinzufügen</button>
|
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>
|
</form>
|
||||||
|
|
||||||
<ul className="simple-list">
|
<ul className="simple-list">
|
||||||
{locations.map((l) => (
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<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>
|
</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">
|
<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="" />}
|
||||||
<table className="table">
|
<div className="table-wrap">
|
||||||
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
<table className="table">
|
||||||
<tbody>
|
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
||||||
{lots.map((l) => (
|
<tbody>
|
||||||
<tr key={l.id}>
|
{lots.map((l) => (
|
||||||
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
|
<tr key={l.id}>
|
||||||
<td>{l.best_before || "–"}</td>
|
<td className="num">{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
|
||||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
<td>{l.best_before || "–"}</td>
|
||||||
</tr>
|
<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>
|
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,50 +32,66 @@ export default function Products() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<h1>Produkte</h1>
|
<div>
|
||||||
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>}
|
<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>
|
</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 }}>
|
||||||
<table className="table">
|
<div className="table-wrap">
|
||||||
<thead>
|
<table className="table">
|
||||||
<tr>
|
<thead>
|
||||||
<th></th>
|
<tr>
|
||||||
<th>Name</th>
|
<th></th>
|
||||||
<th>Marke</th>
|
<th>Name</th>
|
||||||
<th>Basiseinheit</th>
|
<th>Marke</th>
|
||||||
<th>Bestand</th>
|
<th>Einheit</th>
|
||||||
<th>Mindest</th>
|
<th className="num">Bestand</th>
|
||||||
<th></th>
|
<th className="num">Mindest</th>
|
||||||
</tr>
|
<th></th>
|
||||||
</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
{products.length === 0 && (
|
<tbody>
|
||||||
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr>
|
{products.map((p) => {
|
||||||
)}
|
const low = p.min_stock != null && p.stock < p.min_stock;
|
||||||
</tbody>
|
return (
|
||||||
</table>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
64
web/src/pages/Settings.jsx
Normal file
64
web/src/pages/Settings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
<label>
|
return (
|
||||||
<input type="checkbox" checked={!!checked[it.product_id]}
|
<li key={key} className={checked[key] ? "done" : ""}>
|
||||||
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} />
|
<label>
|
||||||
<span className="item-name">{it.name}</span>
|
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||||
</label>
|
<span className="badge accent">Gruppe</span>
|
||||||
<span className="item-qty">
|
<span className="item-name">{it.name}</span>
|
||||||
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>
|
</label>
|
||||||
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
|
<span className="muted small">
|
||||||
</span>
|
fehlt <strong>{fmt(it.deficit)}</strong> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
||||||
</li>
|
</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>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,38 +54,49 @@ 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}>
|
</td>
|
||||||
<option value="user">Nutzer</option>
|
<td>
|
||||||
<option value="admin">Admin</option>
|
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
|
||||||
</select>
|
disabled={u.id === me.id} style={{ maxWidth: 150 }}>
|
||||||
</td>
|
<option value="user">Benutzer</option>
|
||||||
<td>
|
<option value="admin">Administrator</option>
|
||||||
{u.id !== me.id && (
|
</select>
|
||||||
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button>
|
</td>
|
||||||
)}
|
<td className="num">
|
||||||
</td>
|
{u.id !== me.id && (
|
||||||
</tr>
|
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
|
||||||
))}
|
<Icon name="trash" size={16} />
|
||||||
</tbody>
|
</button>
|
||||||
</table>
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user