From b683f2d93b5a03151c252f838c703d0a697cd5c2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 10:18:30 +0200 Subject: [PATCH] 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 --- backend/app/routers/groups.py | 48 +++- backend/app/routers/views.py | 72 +++++- backend/app/schemas.py | 32 ++- web/src/App.jsx | 84 ++++--- web/src/api.js | 4 + web/src/components/Icon.jsx | 144 ++++++++++++ web/src/pages/CheckIn.jsx | 67 +++--- web/src/pages/CheckOut.jsx | 50 +++-- web/src/pages/Dashboard.jsx | 126 +++++++---- web/src/pages/Groups.jsx | 133 +++++++++++ web/src/pages/History.jsx | 62 +++++ web/src/pages/Locations.jsx | 70 ++++-- web/src/pages/Login.jsx | 14 +- web/src/pages/ProductForm.jsx | 114 ++++++---- web/src/pages/Products.jsx | 95 ++++---- web/src/pages/Settings.jsx | 64 ++++++ web/src/pages/ShoppingList.jsx | 74 ++++-- web/src/pages/Users.jsx | 74 +++--- web/src/styles.css | 398 ++++++++++++++++++++++----------- 19 files changed, 1304 insertions(+), 421 deletions(-) create mode 100644 web/src/components/Icon.jsx create mode 100644 web/src/pages/Groups.jsx create mode 100644 web/src/pages/History.jsx create mode 100644 web/src/pages/Settings.jsx diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index ea36f72..60a1781 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -3,17 +3,27 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, require_admin -from ..models import Group, User -from ..schemas import GroupCreate, GroupOut +from ..models import Group, Product, User +from ..schemas import GroupCreate, GroupOut, GroupUpdate +from ..services.stock import current_stock router = APIRouter(prefix="/groups", tags=["groups"]) +def _group_to_out(db: Session, group: Group) -> GroupOut: + out = GroupOut.model_validate(group) + products = db.query(Product).filter(Product.group_id == group.id).all() + out.product_count = len(products) + out.stock = float(sum(current_stock(db, p.id) for p in products)) + return out + + @router.get("", response_model=list[GroupOut]) def list_groups( db: Session = Depends(get_db), _: User = Depends(get_current_user) -) -> list[Group]: - return db.query(Group).order_by(Group.name).all() +) -> list[GroupOut]: + groups = db.query(Group).order_by(Group.name).all() + return [_group_to_out(db, g) for g in groups] @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED) @@ -21,14 +31,40 @@ def create_group( payload: GroupCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), -) -> Group: +) -> GroupOut: if db.query(Group).filter(Group.name == payload.name).first(): raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") group = Group(name=payload.name, min_stock=payload.min_stock) db.add(group) db.commit() db.refresh(group) - return group + return _group_to_out(db, group) + + +@router.patch("/{group_id}", response_model=GroupOut) +def update_group( + group_id: int, + payload: GroupUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> GroupOut: + group = db.get(Group, group_id) + if group is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") + data = payload.model_dump(exclude_unset=True) + if "name" in data and data["name"]: + clash = ( + db.query(Group) + .filter(Group.name == data["name"], Group.id != group_id) + .first() + ) + if clash: + raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben") + for field, value in data.items(): + setattr(group, field, value) + db.commit() + db.refresh(group) + return _group_to_out(db, group) @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 716f2d8..ba93e97 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -5,8 +5,8 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user -from ..models import Lot, Product, User -from ..schemas import ExpiringItem, ShoppingItem +from ..models import Group, Lot, Movement, Product, User +from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem from ..services.stock import current_stock from .settings import get_expiry_warning_days @@ -41,6 +41,37 @@ def shopping_list( return items +@router.get("/shopping-list/groups", response_model=list[GroupShoppingItem]) +def group_shopping_list( + db: Session = Depends(get_db), _: User = Depends(get_current_user) +) -> list[GroupShoppingItem]: + """Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt. + + Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten). + Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen. + """ + items: list[GroupShoppingItem] = [] + groups = ( + db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all() + ) + for group in groups: + products = db.query(Product).filter(Product.group_id == group.id).all() + stock = float(sum(current_stock(db, p.id) for p in products)) + if stock < group.min_stock: + items.append( + GroupShoppingItem( + group_id=group.id, + name=group.name, + stock=stock, + min_stock=group.min_stock, + deficit=group.min_stock - stock, + product_count=len(products), + ) + ) + items.sort(key=lambda i: i.deficit, reverse=True) + return items + + @router.get("/expiring", response_model=list[ExpiringItem]) def expiring( days: int | None = None, @@ -74,3 +105,40 @@ def expiring( ) ) return result + + +@router.get("/movements", response_model=list[MovementOut]) +def movements( + limit: int = 100, + product_id: int | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[MovementOut]: + """Bewegungsverlauf (wer hat wann was ein-/ausgelagert), neueste zuerst.""" + limit = max(1, min(limit, 500)) + query = ( + db.query(Movement, Product, User) + .join(Product, Movement.product_id == Product.id) + .outerjoin(User, Movement.user_id == User.id) + ) + if product_id is not None: + query = query.filter(Movement.product_id == product_id) + rows = query.order_by(Movement.created_at.desc(), Movement.id.desc()).limit(limit).all() + + result: list[MovementOut] = [] + for movement, product, user in rows: + result.append( + MovementOut( + id=movement.id, + product_id=product.id, + product_name=product.name, + type=movement.type.value, + quantity=movement.quantity, + base_unit=product.base_unit, + unit_used=movement.unit_used, + username=user.username if user else None, + note=movement.note, + created_at=movement.created_at, + ) + ) + return result diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f8456c9..c981c30 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -40,11 +40,19 @@ class GroupOut(BaseModel): id: int name: str min_stock: float | None = None + # angereichert: + product_count: int = 0 + stock: float = 0.0 class GroupCreate(BaseModel): name: str = Field(min_length=1, max_length=120) - min_stock: float | None = None + min_stock: float | None = Field(default=None, ge=0) + + +class GroupUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + min_stock: float | None = Field(default=None, ge=0) # ---- Locations ---- @@ -169,6 +177,28 @@ class ExpiringItem(BaseModel): days_left: int +class GroupShoppingItem(BaseModel): + group_id: int + name: str + stock: float + min_stock: float + deficit: float + product_count: int + + +class MovementOut(BaseModel): + id: int + product_id: int + product_name: str + type: str + quantity: float + base_unit: BaseUnit + unit_used: str + username: str | None + note: str | None + created_at: datetime + + class SettingOut(BaseModel): key: str value: str diff --git a/web/src/App.jsx b/web/src/App.jsx index e52c0c5..e05fe83 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,16 +1,31 @@ import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { useAuth } from "./auth"; +import Icon from "./components/Icon"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Products from "./pages/Products"; import ProductForm from "./pages/ProductForm"; import CheckIn from "./pages/CheckIn"; import CheckOut from "./pages/CheckOut"; +import Groups from "./pages/Groups"; import Locations from "./pages/Locations"; import Users from "./pages/Users"; import ShoppingList from "./pages/ShoppingList"; +import History from "./pages/History"; +import Settings from "./pages/Settings"; -function Layout({ children }) { +const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link"); + +function NavItem({ to, icon, label, end }) { + return ( + + + {label} + + ); +} + +function Sidebar() { const { user, isAdmin, logout } = useAuth(); const navigate = useNavigate(); @@ -19,39 +34,55 @@ function Layout({ children }) { navigate("/login"); } - const link = ({ isActive }) => (isActive ? "nav-link active" : "nav-link"); - return ( -
-
-
🥫 Project-Good
- -
- - {user?.username} {isAdmin && Admin} - - +
-
{children}
-
+ + + ); } function Protected({ children, adminOnly = false }) { const { user, isAdmin, loading } = useAuth(); - if (loading) return
Lädt…
; + if (loading) return
Lädt…
; if (!user) return ; if (adminOnly && !isAdmin) return ; - return {children}; + return ( +
+ +
+
{children}
+
+
+ ); } export default function App() { @@ -69,9 +100,12 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> } /> } /> + } /> } /> ); diff --git a/web/src/api.js b/web/src/api.js index f9b8232..36b60c7 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -90,7 +90,9 @@ export const api = { // Views shoppingList: () => request("/shopping-list"), + groupShoppingList: () => request("/shopping-list/groups"), expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`), + listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`), // Stammdaten listLocations: () => request("/locations"), @@ -99,6 +101,8 @@ export const api = { listGroups: () => request("/groups"), createGroup: (body) => request("/groups", { method: "POST", body }), + updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), + deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }), // Benutzer listUsers: () => request("/users"), diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx new file mode 100644 index 0000000..0489236 --- /dev/null +++ b/web/src/components/Icon.jsx @@ -0,0 +1,144 @@ +// Schlankes Inline-SVG-Icon-Set (Feather-Stil). Bewusst keine Emojis. +// Verwendung: oder + +const PATHS = { + dashboard: ( + <> + + + + + + ), + checkin: ( + <> + + + + + ), + checkout: ( + <> + + + + + ), + package: ( + <> + + + + + + ), + tag: ( + <> + + + + ), + location: ( + <> + + + + ), + cart: ( + <> + + + + + ), + users: ( + <> + + + + + + ), + clock: ( + <> + + + + ), + history: ( + <> + + + + + ), + settings: ( + <> + + + + ), + logout: ( + <> + + + + + ), + search: ( + <> + + + + ), + plus: , + trash: ( + <> + + + + + + ), + edit: ( + <> + + + + ), + alert: ( + <> + + + + ), + check: , + chevronRight: , + box: ( + <> + + + ), +}; + +export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) { + const path = PATHS[name]; + if (!path) return null; + return ( + + ); +} diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index da704e9..10bac1a 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; +import Icon from "../components/Icon"; import { fmt, unitOptions, unitShort } from "../units"; export default function CheckIn() { @@ -30,8 +31,7 @@ export default function CheckIn() { setProduct(p); setResults([]); setUnknownBarcode(null); - const opts = unitOptions(p); - setUnit(opts[0].value); + setUnit(unitOptions(p)[0].value); } async function doLookup() { @@ -44,7 +44,6 @@ export default function CheckIn() { setInfo(`Produkt erkannt: ${res.existing_product.name}`); } else { setUnknownBarcode(barcode.trim()); - setInfo(null); } } catch (err) { setError(err.message); @@ -84,36 +83,40 @@ export default function CheckIn() { return (
-

📥 Einlagern

- {error &&
{error}
} - {info &&
{info}
} +

Einlagern

+ {error &&
{error}
} + {info &&
{info}
} {!product && (
-

Per Barcode

-
- setBarcode(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && doLookup()} /> +

Per Barcode

+
+
{unknownBarcode && ( -
- Barcode {unknownBarcode} ist unbekannt.{" "} - {isAdmin ? ( - Produkt anlegen - ) : ( - "Bitte einen Administrator bitten, das Produkt anzulegen." - )} +
+ + + Barcode {unknownBarcode} ist unbekannt.{" "} + {isAdmin + ? Produkt anlegen + : "Bitte einen Administrator bitten, das Produkt anzulegen."} +
)}
-

Aus Produktliste

-
- setSearch(e.target.value)} /> +

Aus Produktliste

+ +
    @@ -132,12 +135,12 @@ export default function CheckIn() { {product && (
    - {product.image_url && } -
    - {product.name} -
    - Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)} -
    + {product.image_url + ? + : } +
    +
    {product.name}
    +
    Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}
    @@ -151,15 +154,13 @@ export default function CheckIn() {
    - +
    )}
diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx index e2fcda9..d2d7f16 100644 --- a/web/src/pages/CheckOut.jsx +++ b/web/src/pages/CheckOut.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { api } from "../api"; +import Icon from "../components/Icon"; import { fmt, unitOptions, unitShort } from "../units"; export default function CheckOut() { @@ -54,7 +55,7 @@ export default function CheckOut() { quantity: Number(quantity), unit, }); - setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n) betroffen). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`); + setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`); setQuantity(""); setProduct(await api.getProduct(product.id)); } catch (err) { @@ -66,26 +67,29 @@ export default function CheckOut() { return (
-

📤 Auslagern

- {error &&
{error}
} - {info &&
{info}
} +

Auslagern

+ {error &&
{error}
} + {info &&
{info}
} {!product && (
-

Per Barcode

-
- setBarcode(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && doLookup()} /> +

Per Barcode

+
+
-

Aus Produktliste

-
- setSearch(e.target.value)} /> +

Aus Produktliste

+ +
    @@ -104,12 +108,12 @@ export default function CheckOut() { {product && (
    - {product.image_url && } -
    - {product.name} -
    - VerfĂĽgbar: {fmt(product.stock)} {unitShort(product.base_unit)} -
    + {product.image_url + ? + : } +
    +
    {product.name}
    +
    VerfĂĽgbar: {fmt(product.stock)} {unitShort(product.base_unit)}
    @@ -123,16 +127,14 @@ export default function CheckOut() {
    -

    +

    Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).

    - +
    )}
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 1570290..eb4c8ad 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -1,19 +1,26 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; +import Icon from "../components/Icon"; import { fmt, unitShort } from "../units"; export default function Dashboard() { const [expiring, setExpiring] = useState([]); const [shopping, setShopping] = useState([]); + const [groupShopping, setGroupShopping] = useState([]); const [error, setError] = useState(null); useEffect(() => { async function load() { try { - const [e, s] = await Promise.all([api.expiring(), api.shoppingList()]); + const [e, s, g] = await Promise.all([ + api.expiring(), + api.shoppingList(), + api.groupShoppingList(), + ]); setExpiring(e); setShopping(s); + setGroupShopping(g); } catch (err) { setError(err.message); } @@ -21,61 +28,100 @@ export default function Dashboard() { load(); }, []); + const overdue = expiring.filter((e) => e.days_left < 0).length; + return (
-

Ăśbersicht

+
+

Ăśbersicht

+
Bestand, Ablauf und Bedarf auf einen Blick
+
- Einlagern - Auslagern + Einlagern + Auslagern +
+
+ + {error &&
{error}
} + +
+
+
Bald ablaufend
+
{expiring.length}
+
+
+
Überfällig
+
{overdue}
+
+
+
Nachzukaufen
+
{shopping.length + groupShopping.length}
- {error &&
{error}
}
-

⏰ Bald ablaufend

+
+

Bald ablaufend

+
{expiring.length === 0 ? ( -

Nichts läuft demnächst ab. 🎉

+
Nichts läuft demnächst ab.
) : ( - - - - - - {expiring.map((it) => ( - - - - - - - ))} - -
ProduktMengeMHDTage
{it.product_name}{fmt(it.quantity)} {unitShort(it.base_unit)}{it.best_before}{it.days_left < 0 ? `${-it.days_left} ĂĽberf.` : it.days_left}
+
+ + + + + + {expiring.map((it) => ( + + + + + + + ))} + +
ProduktMengeMHDTage
{it.product_name}{fmt(it.quantity)} {unitShort(it.base_unit)}{it.best_before} + {it.days_left < 0 + ? {-it.days_left}d ĂĽberf. + : it.days_left} +
+
)}
-

đź›’ Einkaufsliste

- {shopping.length === 0 ? ( -

Alle Mindestbestände erreicht. 👍

+
+

Einkaufsliste

+
+ {shopping.length === 0 && groupShopping.length === 0 ? ( +
Alle Mindestbestände erreicht.
) : ( - - - - - - {shopping.map((it) => ( - - - - - - - ))} - -
ProduktBestandMindestFehlt
{it.name}{fmt(it.stock)} {unitShort(it.base_unit)}{fmt(it.min_stock)}{fmt(it.deficit)} {unitShort(it.base_unit)}
+
+ + + + + + {groupShopping.map((it) => ( + + + + + + ))} + {shopping.map((it) => ( + + + + + + ))} + +
BedarfBestandFehlt
Gruppe {it.name}{fmt(it.stock)}{fmt(it.deficit)}
{it.name}{fmt(it.stock)} {unitShort(it.base_unit)}{fmt(it.deficit)} {unitShort(it.base_unit)}
+
)}
diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx new file mode 100644 index 0000000..508c67a --- /dev/null +++ b/web/src/pages/Groups.jsx @@ -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 ( +
+
+
+

Gruppen

+
Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand
+
+
+ {error &&
{error}
} + +
+
+
+ + + + + + {groups.map((g) => { + const low = g.min_stock != null && g.stock < g.min_stock; + return ( + + + + + + + + ); + })} + {groups.length === 0 && } + +
GruppeProdukteBestandMindestbestand
+ {g.name} + {low && niedrig} + {g.product_count}{fmt(g.stock)} + {isAdmin ? ( + { + const v = e.target.value; + if (v !== String(g.min_stock ?? "")) saveMin(g, v); + }} /> + ) : (g.min_stock != null ? fmt(g.min_stock) : "–")} + + {isAdmin && ( + + )} +
Noch keine Gruppen.
+
+
+ + {isAdmin && ( +
+

Neue Gruppe

+ + + +

+ 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. +

+
+ )} +
+
+ ); +} diff --git a/web/src/pages/History.jsx b/web/src/pages/History.jsx new file mode 100644 index 0000000..da962fd --- /dev/null +++ b/web/src/pages/History.jsx @@ -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 ( +
+
+
+

Verlauf

+
Wer hat wann was ein- und ausgelagert
+
+
+ {error &&
{error}
} + +
+
+ + + + + + + + + + + + {movements.map((m) => ( + + + + + + + + ))} + {movements.length === 0 && ( + + )} + +
ZeitpunktAktionProduktMengeBenutzer
{new Date(m.created_at).toLocaleString("de-DE")} + + + {TYPE_LABEL[m.type] || m.type} + + {m.product_name}{fmt(m.quantity)} {unitShort(m.base_unit)}{m.username || "–"}
Noch keine Bewegungen.
+
+
+
+ ); +} diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index 03a93dd..c347741 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import Icon from "../components/Icon"; export default function Locations() { const [locations, setLocations] = useState([]); const [name, setName] = useState(""); + const [parentId, setParentId] = useState(""); const [error, setError] = useState(null); async function load() { @@ -20,8 +22,9 @@ export default function Locations() { e.preventDefault(); setError(null); try { - await api.createLocation({ name }); + await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) }); setName(""); + setParentId(""); load(); } catch (err) { setError(err.message); @@ -29,7 +32,7 @@ export default function Locations() { } async function remove(id) { - if (!confirm("Lagerort löschen?")) return; + if (!confirm("Lagerort löschen? Untergeordnete Orte werden dann übergeordnet.")) return; try { await api.deleteLocation(id); load(); @@ -38,29 +41,66 @@ export default function Locations() { } } + const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name])); + const roots = locations.filter((l) => !l.parent_id || !nameById[l.parent_id]); + const childrenOf = (pid) => locations.filter((l) => l.parent_id === pid); + const ordered = []; + for (const r of roots) { + ordered.push({ ...r, depth: 0 }); + for (const c of childrenOf(r.id)) ordered.push({ ...c, depth: 1 }); + } + return (
-

Lagerorte

- {error &&
{error}
} +
+
+

Lagerorte

+
Orte lassen sich verschachteln (z.B. Keller → Regal 2 → Fach A)
+
+
+ {error &&
{error}
} +
-
- setName(e.target.value)} required /> - + +
+ + +
+
+
    - {locations.map((l) => ( + {ordered.map((l) => (
  • - {l.name} - + + + {l.name} + {l.depth > 0 && l.parent_id && nameById[l.parent_id] && ( + in {nameById[l.parent_id]} + )} + +
  • ))} - {locations.length === 0 &&
  • Noch keine Lagerorte.
  • } + {locations.length === 0 &&
  • Noch keine Lagerorte.
  • }
-

- Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt. -

); } diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx index f1631d1..11d417b 100644 --- a/web/src/pages/Login.jsx +++ b/web/src/pages/Login.jsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../auth"; +import Icon from "../components/Icon"; export default function Login() { const { login } = useAuth(); @@ -27,9 +28,14 @@ export default function Login() { return (
-
🥫 Project-Good
-

Lebensmittel-Lagerverwaltung

- {error &&
{error}
} +
+ + Project-Good +
+

Lebensmittel-Lagerverwaltung

+ {error && ( +
{error}
+ )} -
diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 9c6f7ea..69cdf4d 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -2,19 +2,28 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; +import Icon from "../components/Icon"; import { BASE_UNITS, fmt, unitShort } from "../units"; const EMPTY = { - barcode: "", - name: "", - brand: "", - image_url: "", - base_unit: "piece", - package_size: "", - min_stock: "", - group_id: "", + barcode: "", name: "", brand: "", image_url: "", + base_unit: "piece", package_size: "", min_stock: "", group_id: "", }; +// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten. +function guessGroup(groups, suggestion) { + if (!suggestion) return ""; + const haystack = [ + suggestion.category_suggestion || "", + ...(suggestion.category_tags || []), + suggestion.name || "", + ] + .join(" ") + .toLowerCase(); + const hit = groups.find((g) => haystack.includes(g.name.toLowerCase())); + return hit ? String(hit.id) : ""; +} + export default function ProductForm() { const { id } = useParams(); const isNew = !id; @@ -37,13 +46,9 @@ export default function ProductForm() { const p = await api.getProduct(id); setProduct(p); setForm({ - barcode: p.barcode || "", - name: p.name, - brand: p.brand || "", - image_url: p.image_url || "", - base_unit: p.base_unit, - package_size: p.package_size ?? "", - min_stock: p.min_stock ?? "", + barcode: p.barcode || "", name: p.name, brand: p.brand || "", + image_url: p.image_url || "", base_unit: p.base_unit, + package_size: p.package_size ?? "", min_stock: p.min_stock ?? "", group_id: p.group_id ?? "", }); setLots(await api.listLots(id)); @@ -71,14 +76,19 @@ export default function ProductForm() { navigate(`/products/${res.existing_product.id}`); } else if (res.found && res.suggestion) { const s = res.suggestion; + const groupGuess = guessGroup(groups, s); setForm((f) => ({ ...f, name: s.name || f.name, brand: s.brand || f.brand, image_url: s.image_url || f.image_url, base_unit: s.base_unit || f.base_unit, + group_id: f.group_id || groupGuess, })); - setInfo("Vorschlag von Open Food Facts übernommen."); + setInfo( + "Daten von Open Food Facts übernommen." + + (groupGuess ? " Passende Gruppe vorgeschlagen." : "") + ); } else { setInfo("Barcode unbekannt – bitte Daten selbst eingeben."); } @@ -135,21 +145,26 @@ export default function ProductForm() { return (
-

{isNew ? "Neues Produkt" : form.name || "Produkt"}

+
+

{isNew ? "Neues Produkt" : form.name || "Produkt"}

+ {!isNew &&
Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)}
} +
+
- {error &&
{error}
} - {info &&
{info}
} + + {error &&
{error}
} + {info &&
{info}
}
-
+
{isAdmin && ( - )}
@@ -165,21 +180,18 @@ export default function ProductForm() {
@@ -187,39 +199,43 @@ export default function ProductForm() { Gruppe
{isAdmin && ( -
+
- {!isNew && } + {!isNew && ( + + )}
)} - {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} + {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} {!isNew && (
-

Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})

+

Chargen im Bestand

{form.image_url && } - - - - {lots.map((l) => ( - - - - - - ))} - {lots.length === 0 && } - -
MengeMHDEingelagert
{fmt(l.quantity)} {unitShort(form.base_unit)}{l.best_before || "–"}{new Date(l.created_at).toLocaleDateString("de-DE")}
Keine Chargen im Bestand.
+
+ + + + {lots.map((l) => ( + + + + + + ))} + {lots.length === 0 && } + +
MengeMHDEingelagert
{fmt(l.quantity)} {unitShort(form.base_unit)}{l.best_before || "–"}{new Date(l.created_at).toLocaleDateString("de-DE")}
Keine Chargen im Bestand.
+
)}
diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index c5848ed..4e8a4bd 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; +import Icon from "../components/Icon"; import { fmt, unitShort } from "../units"; export default function Products() { @@ -31,50 +32,66 @@ export default function Products() { return (
-

Produkte

- {isAdmin && + Neues Produkt} +
+

Produkte

+
{products.length} Produkte im Katalog
+
+ {isAdmin && ( + Neues Produkt + )}
- {error &&
{error}
} -
- setQ(e.target.value)} /> - + {error &&
{error}
} + + + +
-
- - - - - - - - - - - - - - {products.map((p) => ( - - - - - - - - +
+
+
NameMarkeBasiseinheitBestandMindest
- {p.image_url ? : "📦"} - {p.name}{p.brand || "–"}{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""} - {fmt(p.stock)} {unitShort(p.base_unit)} - {p.min_stock != null ? fmt(p.min_stock) : "–"}Details
+ + + + + + + + + - ))} - {products.length === 0 && ( - - )} - -
NameMarkeEinheitBestandMindest
Keine Produkte.
+ + + {products.map((p) => { + const low = p.min_stock != null && p.stock < p.min_stock; + return ( + + + {p.image_url + ? + : } + + {p.name} + {p.brand || "–"} + {unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""} + + {fmt(p.stock)} {unitShort(p.base_unit)} + {low && niedrig} + + {p.min_stock != null ? fmt(p.min_stock) : "–"} + Details + + ); + })} + {products.length === 0 && ( + Keine Produkte gefunden. + )} + + +
); diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx new file mode 100644 index 0000000..6c99470 --- /dev/null +++ b/web/src/pages/Settings.jsx @@ -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 ( +
+
+
+

Einstellungen

+
Allgemeine Konfiguration der Anwendung
+
+
+ {error &&
{error}
} + {info &&
{info}
} + +
+

Ablaufwarnung

+ +

+ Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder + bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend". +

+ +
+
+ ); +} diff --git a/web/src/pages/ShoppingList.jsx b/web/src/pages/ShoppingList.jsx index e6b0402..b0923e7 100644 --- a/web/src/pages/ShoppingList.jsx +++ b/web/src/pages/ShoppingList.jsx @@ -1,46 +1,76 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import Icon from "../components/Icon"; import { fmt, unitShort } from "../units"; export default function ShoppingList() { const [items, setItems] = useState([]); + const [groups, setGroups] = useState([]); const [checked, setChecked] = useState({}); const [error, setError] = useState(null); useEffect(() => { - api.shoppingList().then(setItems).catch((e) => setError(e.message)); + Promise.all([api.shoppingList(), api.groupShoppingList()]) + .then(([p, g]) => { setItems(p); setGroups(g); }) + .catch((e) => setError(e.message)); }, []); + const empty = items.length === 0 && groups.length === 0; + + function toggle(key, val) { + setChecked((c) => ({ ...c, [key]: val })); + } + return (
-

đź›’ Einkaufsliste

- {error &&
{error}
} -

- Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt. -

+
+
+

Einkaufsliste

+
Produkte und Gruppen unter ihrem Mindestbestand
+
+
+ {error &&
{error}
} +
- {items.length === 0 ? ( -

Alle Mindestbestände erreicht. 👍

+ {empty ? ( +
Alle Mindestbestände erreicht.
) : (
    - {items.map((it) => ( -
  • - - - fehlt {fmt(it.deficit)} {unitShort(it.base_unit)} - (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) - -
  • - ))} + {groups.map((it) => { + const key = `g${it.group_id}`; + return ( +
  • + + + fehlt {fmt(it.deficit)} (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + +
  • + ); + })} + {items.map((it) => { + const key = `p${it.product_id}`; + return ( +
  • + + + fehlt {fmt(it.deficit)} {unitShort(it.base_unit)}{" "} + (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + +
  • + ); + })}
)}

- 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.

); diff --git a/web/src/pages/Users.jsx b/web/src/pages/Users.jsx index 046d414..af2a88f 100644 --- a/web/src/pages/Users.jsx +++ b/web/src/pages/Users.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useAuth } from "../auth"; +import Icon from "../components/Icon"; export default function Users() { const { user: me } = useAuth(); @@ -53,38 +54,49 @@ export default function Users() { return (
-

Benutzer

- {error &&
{error}
} - {info &&
{info}
} +
+
+

Benutzer

+
Administratoren verwalten alles, Benutzer lagern nur ein und aus
+
+
+ {error &&
{error}
} + {info &&
{info}
}
-
-

Benutzer

- - - - {users.map((u) => ( - - - - - - ))} - -
NameRolle
{u.username}{u.id === me.id && (du)} - - - {u.id !== me.id && ( - - )} -
+
+
+ + + + {users.map((u) => ( + + + + + + ))} + +
NameRolle
+ {u.username}{u.id === me.id && (du)} + + + + {u.id !== me.id && ( + + )} +
+
-
-

Neuer Benutzer

+ +

Neuer Benutzer

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