Fix order saving, isMyOrder, blocked waiters, options pricing; add preferences, table groups, product images

Backend:
- OrderItemInput accepts option objects {id,name,price_delta} instead of int IDs
- extra_cost from selected options added to unit_price snapshot
- GET /api/products/?all=true for manager (includes unavailable)
- PUT /api/products/{id} now replaces options, ingredients, preference_sets
- POST /api/products/{id}/image — persistent image upload to /app/data/product_images
- New models: ProductPreferenceSet, ProductPreferenceChoice, TableGroup
- tables: group_id FK, hard delete (?hard=true), batch create POST /api/tables/batch
- GET /api/tables/groups + POST/PUT/DELETE groups endpoints
- POST /api/auth/me endpoint for token rehydration
- Auto-migration on startup for new columns

PWA:
- AuthRehydrator: fetches /auth/me on load so isMyOrder works after page reload
- 401 response force-logs out (covers blocked waiters)
- ItemOptionsModal: uses extra_cost correctly, shows preferences as radio buttons

Manager:
- ProductsPage: shows unavailable products greyed out, category color picker + reorder,
  full option/ingredient/preference editing, image upload
- TablesPage: table groups, auto-increment, deactivate vs hard delete, batch add
This commit is contained in:
2026-04-20 18:39:51 +03:00
parent 8f52156f5b
commit 24a029a8cc
16 changed files with 826 additions and 172 deletions

View File

@@ -10,6 +10,7 @@ services:
- ./local_backend/pos.db:/app/pos.db - ./local_backend/pos.db:/app/pos.db
- ./local_backend/license_state.json:/app/license_state.json - ./local_backend/license_state.json:/app/license_state.json
- ./logo.png:/app/logo.png:ro - ./logo.png:/app/logo.png:ro
- ./data/product_images:/app/data/product_images
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@@ -1,6 +1,8 @@
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from database import engine, Base from database import engine, Base
from middleware.license_check import LicenseCheckMiddleware from middleware.license_check import LicenseCheckMiddleware
@@ -16,9 +18,34 @@ import models.order # noqa: F401
from routers import auth, tables, products, orders, waiters, reports, system from routers import auth, tables, products, orders, waiters, reports, system
def _run_migrations():
"""Apply additive schema changes that create_all won't handle."""
from sqlalchemy import text
with engine.connect() as conn:
# Add extra_cost to product_ingredients if missing
try:
conn.execute(text("ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0"))
conn.commit()
except Exception:
pass
# Add image_url to products if missing
try:
conn.execute(text("ALTER TABLE products ADD COLUMN image_url VARCHAR"))
conn.commit()
except Exception:
pass
# Add group_id to tables if missing (added in Phase 3 table groups)
try:
conn.execute(text("ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)"))
conn.commit()
except Exception:
pass
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations()
sync_task = await start_cloud_sync() sync_task = await start_cloud_sync()
yield yield
sync_task.cancel() sync_task.cancel()
@@ -34,6 +61,11 @@ app.add_middleware(
) )
app.add_middleware(LicenseCheckMiddleware) app.add_middleware(LicenseCheckMiddleware)
# Serve product images as static files
IMAGE_DIR = "/app/data/product_images"
os.makedirs(IMAGE_DIR, exist_ok=True)
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(tables.router, prefix="/api/tables", tags=["tables"]) app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
app.include_router(products.router, prefix="/api/products", tags=["products"]) app.include_router(products.router, prefix="/api/products", tags=["products"])

View File

@@ -23,11 +23,13 @@ class Product(Base):
base_price = Column(Float, nullable=False) base_price = Column(Float, nullable=False)
is_available = Column(Boolean, default=True, nullable=False) is_available = Column(Boolean, default=True, nullable=False)
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True) printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
image_url = Column(String, nullable=True)
category = relationship("Category", back_populates="products") category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products") printer_zone = relationship("Printer", back_populates="products")
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan") options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan") ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
order_items = relationship("OrderItem", back_populates="product") order_items = relationship("OrderItem", back_populates="product")
@@ -48,5 +50,28 @@ class ProductIngredient(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False) product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
product = relationship("Product", back_populates="ingredients") product = relationship("Product", back_populates="ingredients")
class ProductPreferenceSet(Base):
__tablename__ = "product_preference_sets"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
product = relationship("Product", back_populates="preference_sets")
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
class ProductPreferenceChoice(Base):
__tablename__ = "product_preference_choices"
id = Column(Integer, primary_key=True, index=True)
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
set = relationship("ProductPreferenceSet", back_populates="choices")

View File

@@ -1,16 +1,28 @@
from sqlalchemy import Column, Integer, String, Boolean, Float from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
class TableGroup(Base):
__tablename__ = "table_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True)
sort_order = Column(Integer, default=0)
tables = relationship("Table", back_populates="group")
class Table(Base): class Table(Base):
__tablename__ = "tables" __tablename__ = "tables"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
number = Column(Integer, unique=True, nullable=False) number = Column(Integer, nullable=False)
label = Column(String, nullable=True) label = Column(String, nullable=True)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
floor_x = Column(Float, nullable=True) floor_x = Column(Float, nullable=True)
floor_y = Column(Float, nullable=True) floor_y = Column(Float, nullable=True)
group = relationship("TableGroup", back_populates="tables")
orders = relationship("Order", back_populates="table") orders = relationship("Order", back_populates="table")

View File

@@ -62,3 +62,8 @@ def refresh(token: str, db: Session = Depends(get_db)):
def logout(token: str): def logout(token: str):
_blacklisted_tokens.add(token) _blacklisted_tokens.add(token)
return {"status": "logged out"} return {"status": "logged out"}
@router.get("/me", response_model=UserOut)
def me(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return user

View File

@@ -109,13 +109,18 @@ def add_items(
product = db.query(Product).filter(Product.id == item_in.product_id).first() product = db.query(Product).filter(Product.id == item_in.product_id).first()
if not product or not product.is_available: if not product or not product.is_available:
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
# Calculate extra cost from selected options
extra_cost = sum(
(o.price_delta or o.extra_cost or 0.0)
for o in (item_in.selected_options or [])
)
item = OrderItem( item = OrderItem(
order_id=order_id, order_id=order_id,
product_id=item_in.product_id, product_id=item_in.product_id,
added_by=user.id, added_by=user.id,
quantity=item_in.quantity, quantity=item_in.quantity,
unit_price=product.base_price, # price snapshot unit_price=product.base_price + extra_cost, # price snapshot with options
selected_options=json.dumps(item_in.selected_options) if item_in.selected_options else None, selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
notes=item_in.notes, notes=item_in.notes,
) )

View File

@@ -1,20 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException, status import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from database import get_db from database import get_db
from models.product import Product, Category, ProductOption, ProductIngredient from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.user import User from models.user import User
from schemas.product import ( from schemas.product import (
ProductCreate, ProductUpdate, ProductOut, ProductCreate, ProductUpdate, ProductOut,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate,
) )
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
router = APIRouter() router = APIRouter()
IMAGE_DIR = "/app/data/product_images"
# ── Categories ───────────────────────────────────────────────────────────────
def _replace_options(db, product, options):
for opt in product.options:
db.delete(opt)
db.flush()
for opt in options:
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
def _replace_ingredients(db, product, ingredients):
for ing in product.ingredients:
db.delete(ing)
db.flush()
for ing in ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
for ps in product.preference_sets:
db.delete(ps)
db.flush()
for ps in sets:
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
db.add(new_set)
db.flush()
for ch in ps.choices:
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
# ── Categories ────────────────────────────────────────────────────────────────
@router.get("/categories", response_model=List[CategoryOut]) @router.get("/categories", response_model=List[CategoryOut])
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
@@ -23,13 +56,23 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) @router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
cat = Category(**body.model_dump()) max_order = db.query(Category).count()
cat = Category(name=body.name, color=body.color, sort_order=max_order)
db.add(cat) db.add(cat)
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
return cat return cat
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.sort_order = item.sort_order
db.commit()
@router.put("/categories/{category_id}", response_model=CategoryOut) @router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
cat = db.query(Category).filter(Category.id == category_id).first() cat = db.query(Category).filter(Category.id == category_id).first()
@@ -54,13 +97,16 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
# ── Products ────────────────────────────────────────────────────────────────── # ── Products ──────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[ProductOut]) @router.get("/", response_model=List[ProductOut])
def list_products(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Product).filter(Product.is_available == True).all() q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
q = q.filter(Product.is_available == True)
return q.all()
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"options", "ingredients"}) data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
product = Product(**data) product = Product(**data)
db.add(product) db.add(product)
db.flush() db.flush()
@@ -68,6 +114,12 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
db.add(ProductOption(product_id=product.id, **opt.model_dump())) db.add(ProductOption(product_id=product.id, **opt.model_dump()))
for ing in body.ingredients: for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump())) db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
for ps in body.preference_sets:
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
db.add(new_set)
db.flush()
for ch in ps.choices:
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
db.commit() db.commit()
db.refresh(product) db.refresh(product)
return product return product
@@ -78,8 +130,45 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
product = db.query(Product).filter(Product.id == product_id).first() product = db.query(Product).filter(Product.id == product_id).first()
if not product: if not product:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
for field, value in body.model_dump(exclude_none=True).items(): for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
setattr(product, field, value) setattr(product, field, value)
if body.options is not None:
_replace_options(db, product, body.options)
if body.ingredients is not None:
_replace_ingredients(db, product, body.ingredients)
if body.preference_sets is not None:
_replace_preference_sets(db, product, body.preference_sets)
db.commit()
db.refresh(product)
return product
@router.post("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
os.makedirs(IMAGE_DIR, exist_ok=True)
# Delete old image if exists
if product.image_url:
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
if os.path.exists(old_path):
os.remove(old_path)
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "jpg"
filename = f"{product_id}_{uuid.uuid4().hex[:8]}.{ext}"
filepath = os.path.join(IMAGE_DIR, filename)
contents = await file.read()
with open(filepath, "wb") as f:
f.write(contents)
product.image_url = f"/static/product_images/{filename}"
db.commit() db.commit()
db.refresh(product) db.refresh(product)
return product return product

View File

@@ -3,24 +3,72 @@ from sqlalchemy.orm import Session
from typing import List from typing import List
from database import get_db from database import get_db
from models.table import Table from models.table import Table, TableGroup
from models.order import Order from models.order import Order
from models.user import User from models.user import User
from schemas.table import TableCreate, TableUpdate, TableFloorplanUpdate, TableOut from schemas.table import (
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
TableGroupCreate, TableGroupUpdate, TableGroupOut,
TableBatchCreate,
)
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
router = APIRouter() router = APIRouter()
# ── Table Groups ──────────────────────────────────────────────────────────────
@router.get("/groups", response_model=List[TableGroupOut])
def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(TableGroup).order_by(TableGroup.sort_order).all()
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
group = TableGroup(name=body.name, sort_order=sort_order)
db.add(group)
db.commit()
db.refresh(group)
return group
@router.put("/groups/{group_id}", response_model=TableGroupOut)
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(group, field, value)
db.commit()
db.refresh(group)
return group
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
db.query(Table).filter(Table.group_id == group_id).update({"group_id": None})
db.delete(group)
db.commit()
# ── Tables ────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[TableOut]) @router.get("/", response_model=List[TableOut])
def list_tables(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Table).filter(Table.is_active == True).all() q = db.query(Table)
if not include_inactive:
q = q.filter(Table.is_active == True)
return q.order_by(Table.group_id, Table.number).all()
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
if db.query(Table).filter(Table.number == body.number).first():
raise HTTPException(status_code=400, detail="Table number already exists")
table = Table(**body.model_dump()) table = Table(**body.model_dump())
db.add(table) db.add(table)
db.commit() db.commit()
@@ -28,6 +76,28 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
return table return table
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
if body.count < 1 or body.count > 200:
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
created = []
for i in range(body.count):
n = body.start_number + i
table = Table(
number=n,
label=f"{body.name_prefix}{n}",
group_id=body.group_id,
is_active=True,
)
db.add(table)
db.flush()
created.append(table)
db.commit()
for t in created:
db.refresh(t)
return created
@router.put("/{table_id}", response_model=TableOut) @router.put("/{table_id}", response_model=TableOut)
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
table = db.query(Table).filter(Table.id == table_id).first() table = db.query(Table).filter(Table.id == table_id).first()
@@ -41,11 +111,20 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
def deactivate_table(table_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
table = db.query(Table).filter(Table.id == table_id).first() table = db.query(Table).filter(Table.id == table_id).first()
if not table: if not table:
raise HTTPException(status_code=404, detail="Table not found") raise HTTPException(status_code=404, detail="Table not found")
table.is_active = False if hard:
active_order = db.query(Order).filter(
Order.table_id == table_id,
Order.status.in_(["open", "partially_paid"])
).first()
if active_order:
raise HTTPException(status_code=400, detail="Cannot delete table with active order")
db.delete(table)
else:
table.is_active = False
db.commit() db.commit()

View File

@@ -3,11 +3,18 @@ from datetime import datetime
from typing import Optional, List from typing import Optional, List
class SelectedOptionInput(BaseModel):
id: Optional[int] = None
name: Optional[str] = None
price_delta: Optional[float] = None
extra_cost: Optional[float] = None
class OrderItemInput(BaseModel): class OrderItemInput(BaseModel):
product_id: int product_id: int
quantity: int quantity: int
selected_options: Optional[List[int]] = None selected_options: Optional[List[SelectedOptionInput]] = None
removed_ingredients: Optional[List[int]] = None removed_ingredients: Optional[List[str]] = None
notes: Optional[str] = None notes: Optional[str] = None

View File

@@ -2,28 +2,34 @@ from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
class CategoryBase(BaseModel): class CategoryCreate(BaseModel):
name: str name: str
color: Optional[str] = None color: Optional[str] = None
sort_order: int = 0 sort_order: int = 0
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel): class CategoryUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
class CategoryOut(CategoryBase): class CategoryOut(BaseModel):
id: int id: int
name: str
color: Optional[str] = None
sort_order: int = 0
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class CategoryReorderItem(BaseModel):
id: int
sort_order: int
# ── Options ──────────────────────────────────────────────────────────────────
class ProductOptionBase(BaseModel): class ProductOptionBase(BaseModel):
name: str name: str
extra_cost: float = 0.0 extra_cost: float = 0.0
@@ -40,8 +46,11 @@ class ProductOptionOut(ProductOptionBase):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── Ingredients ───────────────────────────────────────────────────────────────
class ProductIngredientBase(BaseModel): class ProductIngredientBase(BaseModel):
name: str name: str
extra_cost: float = 0.0
class ProductIngredientCreate(ProductIngredientBase): class ProductIngredientCreate(ProductIngredientBase):
@@ -55,6 +64,42 @@ class ProductIngredientOut(ProductIngredientBase):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── Preferences ───────────────────────────────────────────────────────────────
class PreferenceChoiceBase(BaseModel):
name: str
extra_cost: float = 0.0
class PreferenceChoiceCreate(PreferenceChoiceBase):
pass
class PreferenceChoiceOut(PreferenceChoiceBase):
id: int
set_id: int
model_config = {"from_attributes": True}
class PreferenceSetBase(BaseModel):
name: str
class PreferenceSetCreate(PreferenceSetBase):
choices: List[PreferenceChoiceCreate] = []
class PreferenceSetOut(PreferenceSetBase):
id: int
product_id: int
choices: List[PreferenceChoiceOut] = []
model_config = {"from_attributes": True}
# ── Products ──────────────────────────────────────────────────────────────────
class ProductBase(BaseModel): class ProductBase(BaseModel):
name: str name: str
category_id: Optional[int] = None category_id: Optional[int] = None
@@ -66,6 +111,7 @@ class ProductBase(BaseModel):
class ProductCreate(ProductBase): class ProductCreate(ProductBase):
options: List[ProductOptionCreate] = [] options: List[ProductOptionCreate] = []
ingredients: List[ProductIngredientCreate] = [] ingredients: List[ProductIngredientCreate] = []
preference_sets: List[PreferenceSetCreate] = []
class ProductUpdate(BaseModel): class ProductUpdate(BaseModel):
@@ -74,11 +120,16 @@ class ProductUpdate(BaseModel):
base_price: Optional[float] = None base_price: Optional[float] = None
is_available: Optional[bool] = None is_available: Optional[bool] = None
printer_zone_id: Optional[int] = None printer_zone_id: Optional[int] = None
options: Optional[List[ProductOptionCreate]] = None
ingredients: Optional[List[ProductIngredientCreate]] = None
preference_sets: Optional[List[PreferenceSetCreate]] = None
class ProductOut(ProductBase): class ProductOut(ProductBase):
id: int id: int
options: List[ProductOptionOut] = [] options: List[ProductOptionOut] = []
ingredients: List[ProductIngredientOut] = [] ingredients: List[ProductIngredientOut] = []
preference_sets: List[PreferenceSetOut] = []
image_url: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -1,10 +1,27 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
class TableGroupCreate(BaseModel):
name: str
class TableGroupUpdate(BaseModel):
name: Optional[str] = None
class TableGroupOut(BaseModel):
id: int
name: str
sort_order: int = 0
model_config = {"from_attributes": True}
class TableBase(BaseModel): class TableBase(BaseModel):
number: int number: int
label: Optional[str] = None label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True is_active: bool = True
@@ -12,9 +29,17 @@ class TableCreate(TableBase):
pass pass
class TableBatchCreate(BaseModel):
group_id: Optional[int] = None
count: int
name_prefix: str # e.g. "Out-" → Out-1, Out-2 ...
start_number: int = 1
class TableUpdate(BaseModel): class TableUpdate(BaseModel):
number: Optional[int] = None number: Optional[int] = None
label: Optional[str] = None label: Optional[str] = None
group_id: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@@ -27,5 +52,6 @@ class TableOut(TableBase):
id: int id: int
floor_x: Optional[float] = None floor_x: Optional[float] = None
floor_y: Optional[float] = None floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -4,30 +4,54 @@ import toast from 'react-hot-toast'
import client from '../api/client' import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
const EMPTY_PRODUCT = { name: '', category_id: '', base_price: '', is_available: true, printer_zone_id: '', options: [], ingredients: [] } const EMPTY_PRODUCT = {
name: '', category_id: '', base_price: '', is_available: true,
printer_zone_id: '', options: [], ingredients: [], preference_sets: [],
}
// ── Category colour swatch ────────────────────────────────────────────────────
const COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
function ColorPicker({ value, onChange }) {
return (
<div className="flex flex-wrap gap-2 mt-1">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => onChange(c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
/>
))}
</div>
)
}
export default function ProductsPage() { export default function ProductsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [selectedCat, setSelectedCat] = useState(null) const [selectedCat, setSelectedCat] = useState(null)
const [editProduct, setEditProduct] = useState(null) // null | 'new' | product object const [editProduct, setEditProduct] = useState(null)
const [editCat, setEditCat] = useState(null) const [editCat, setEditCat] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null) // { type: 'product'|'category', id } const [confirmDelete, setConfirmDelete] = useState(null)
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['categories'], queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data), queryFn: () => client.get('/api/products/categories').then(r => r.data),
}) })
// Manager fetches ALL products including unavailable ones
const { data: allProducts = [] } = useQuery({ const { data: allProducts = [] } = useQuery({
queryKey: ['products-all'], queryKey: ['products-all'],
queryFn: () => client.get('/api/products/').then(r => r.data), queryFn: () => client.get('/api/products/?all=true').then(r => r.data),
}) })
const { data: printers = [] } = useQuery({ const { data: statusData } = useQuery({
queryKey: ['printers'], queryKey: ['system-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data.printers ?? []), queryFn: () => client.get('/api/system/status').then(r => r.data),
staleTime: 60_000, staleTime: 60_000,
}) })
const printers = statusData?.printers ?? []
const products = selectedCat const products = selectedCat
? allProducts.filter(p => p.category_id === selectedCat) ? allProducts.filter(p => p.category_id === selectedCat)
@@ -39,10 +63,9 @@ export default function ProductsPage() {
} }
const saveCat = useMutation({ const saveCat = useMutation({
mutationFn: (body) => mutationFn: (body) => editCat?.id
editCat?.id ? client.put(`/api/products/categories/${editCat.id}`, body)
? client.put(`/api/products/categories/${editCat.id}`, body) : client.post('/api/products/categories', body),
: client.post('/api/products/categories', body),
onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() }, onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() },
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
@@ -53,18 +76,22 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
const reorderCats = useMutation({
mutationFn: (items) => client.put('/api/products/categories/reorder', items),
onSuccess: () => invalidate(),
})
const saveProduct = useMutation({ const saveProduct = useMutation({
mutationFn: (body) => mutationFn: (body) => editProduct?.id
editProduct?.id ? client.put(`/api/products/${editProduct.id}`, body)
? client.put(`/api/products/${editProduct.id}`, body) : client.post('/api/products/', body),
: client.post('/api/products/', body),
onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() }, onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() },
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
const toggleAvail = useMutation({ const toggleAvail = useMutation({
mutationFn: ({ id, is_available }) => client.put(`/api/products/${id}`, { is_available }), mutationFn: ({ id, is_available }) => client.put(`/api/products/${id}`, { is_available }),
onSuccess: () => { invalidate() }, onSuccess: () => invalidate(),
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
@@ -74,6 +101,19 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
function moveCat(cat, dir) {
const sorted = [...categories].sort((a, b) => a.sort_order - b.sort_order)
const idx = sorted.findIndex(c => c.id === cat.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
const updates = sorted.map((c, i) => {
if (i === idx) return { id: c.id, sort_order: sorted[swapIdx].sort_order }
if (i === swapIdx) return { id: c.id, sort_order: sorted[idx].sort_order }
return { id: c.id, sort_order: c.sort_order }
})
reorderCats.mutate(updates)
}
function handleConfirmDelete() { function handleConfirmDelete() {
if (!confirmDelete) return if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id) if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
@@ -83,7 +123,7 @@ export default function ProductsPage() {
return ( return (
<div className="flex gap-6 h-full"> <div className="flex gap-6 h-full">
{/* Left: Categories */} {/* Left: Categories */}
<aside className="w-56 shrink-0 space-y-2"> <aside className="w-60 shrink-0 space-y-1">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-gray-700">Κατηγορίες</h2> <h2 className="font-semibold text-gray-700">Κατηγορίες</h2>
<button onClick={() => setEditCat({})} className="btn btn-primary text-xs px-2 py-1 min-h-0 h-8">+</button> <button onClick={() => setEditCat({})} className="btn btn-primary text-xs px-2 py-1 min-h-0 h-8">+</button>
@@ -94,16 +134,16 @@ export default function ProductsPage() {
> >
Όλα Όλα
</button> </button>
{categories.map(cat => ( {[...categories].sort((a, b) => a.sort_order - b.sort_order).map((cat, idx, arr) => (
<div key={cat.id} className={`flex items-center gap-1 rounded-xl ${selectedCat === cat.id ? 'bg-primary-700 text-white' : 'hover:bg-gray-100'}`}> <div key={cat.id} className={`flex items-center gap-1 rounded-xl ${selectedCat === cat.id ? 'bg-primary-700 text-white' : 'hover:bg-gray-100'}`}>
<button {cat.color && <span className="w-2.5 h-2.5 rounded-full ml-2 shrink-0" style={{ background: cat.color }} />}
onClick={() => setSelectedCat(cat.id)} <button onClick={() => setSelectedCat(cat.id)} className="flex-1 text-left px-2 py-2.5 text-sm font-medium truncate">
className="flex-1 text-left px-3 py-2.5 text-sm font-medium"
>
{cat.name} {cat.name}
</button> </button>
<button onClick={() => setEditCat(cat)} className="p-1.5 rounded-lg hover:bg-black/10 text-xs"></button> <button onClick={() => moveCat(cat, -1)} disabled={idx === 0} className="p-1 text-xs disabled:opacity-30"></button>
<button onClick={() => setConfirmDelete({ type: 'category', id: cat.id })} className="p-1.5 rounded-lg hover:bg-red-100 text-xs mr-1">🗑</button> <button onClick={() => moveCat(cat, 1)} disabled={idx === arr.length - 1} className="p-1 text-xs disabled:opacity-30"></button>
<button onClick={() => setEditCat(cat)} className="p-1 text-xs"></button>
<button onClick={() => setConfirmDelete({ type: 'category', id: cat.id })} className="p-1 text-xs mr-1">🗑</button>
</div> </div>
))} ))}
</aside> </aside>
@@ -123,41 +163,39 @@ export default function ProductsPage() {
<div className="space-y-3"> <div className="space-y-3">
{products.map(p => ( {products.map(p => (
<div key={p.id} className="card p-4 flex items-center gap-4"> <div key={p.id} className={`card p-4 flex items-center gap-4 transition-opacity ${!p.is_available ? 'opacity-50' : ''}`}>
{p.image_url && (
<img src={`${import.meta.env.VITE_API_URL || ''}${p.image_url}`} alt={p.name} className="w-12 h-12 rounded-lg object-cover shrink-0" />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-gray-800">{p.name}</p> <p className="font-semibold text-gray-800">
{p.name}
{!p.is_available && <span className="ml-2 text-xs text-red-500 font-normal">(μη διαθέσιμο)</span>}
</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{categories.find(c => c.id === p.category_id)?.name ?? '—'} · {categories.find(c => c.id === p.category_id)?.name ?? '—'} · {p.base_price.toFixed(2)}
{p.base_price.toFixed(2)}
{p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`} {p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`}
</p> </p>
</div> </div>
<label className="flex items-center gap-2 cursor-pointer select-none"> <label className="flex items-center gap-2 cursor-pointer select-none shrink-0">
<input <input type="checkbox" checked={p.is_available} onChange={e => toggleAvail.mutate({ id: p.id, is_available: e.target.checked })} className="w-4 h-4 accent-primary-700" />
type="checkbox"
checked={p.is_available}
onChange={e => toggleAvail.mutate({ id: p.id, is_available: e.target.checked })}
className="w-4 h-4 accent-primary-700"
/>
<span className="text-sm text-gray-600">Διαθέσιμο</span> <span className="text-sm text-gray-600">Διαθέσιμο</span>
</label> </label>
<button onClick={() => setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button> <button onClick={() => setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Επεξεργασία</button>
<button onClick={() => setConfirmDelete({ type: 'product', id: p.id })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button> <button onClick={() => setConfirmDelete({ type: 'product', id: p.id })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Απενεργ.</button>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Category form modal */}
{editCat !== null && ( {editCat !== null && (
<CategoryFormModal <CategoryFormModal
cat={editCat} cat={editCat}
onSave={(name, sort_order) => saveCat.mutate({ name, sort_order: Number(sort_order) })} onSave={(name, color) => saveCat.mutate({ name, color })}
onClose={() => setEditCat(null)} onClose={() => setEditCat(null)}
/> />
)} )}
{/* Product form panel */}
{editProduct !== null && ( {editProduct !== null && (
<ProductFormPanel <ProductFormPanel
product={editProduct} product={editProduct}
@@ -170,9 +208,9 @@ export default function ProductsPage() {
{confirmDelete && ( {confirmDelete && (
<ConfirmModal <ConfirmModal
title="Επιβεβαίωση διαγραφής" title="Επιβεβαίωση"
message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί.'} message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί (δεν θα εμφανίζεται στους σερβιτόρους).'}
confirmLabel="Διαγραφή" confirmLabel="Επιβεβαίωση"
confirmClass="btn-danger" confirmClass="btn-danger"
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
onCancel={() => setConfirmDelete(null)} onCancel={() => setConfirmDelete(null)}
@@ -184,7 +222,7 @@ export default function ProductsPage() {
function CategoryFormModal({ cat, onSave, onClose }) { function CategoryFormModal({ cat, onSave, onClose }) {
const [name, setName] = useState(cat.name || '') const [name, setName] = useState(cat.name || '')
const [sort, setSort] = useState(cat.sort_order ?? 0) const [color, setColor] = useState(cat.color || '')
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
@@ -194,12 +232,12 @@ function CategoryFormModal({ cat, onSave, onClose }) {
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus /> <input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div> </div>
<div> <div>
<label className="label">Σειρά ταξινόμησης</label> <label className="label">Χρώμα</label>
<input className="input" type="number" value={sort} onChange={e => setSort(e.target.value)} /> <ColorPicker value={color} onChange={setColor} />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave(name, sort)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button> <button onClick={() => onSave(name, color || null)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div> </div>
</div> </div>
</div> </div>
@@ -214,45 +252,84 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
is_available: product.is_available ?? true, is_available: product.is_available ?? true,
printer_zone_id: product.printer_zone_id || '', printer_zone_id: product.printer_zone_id || '',
options: product.options?.map(o => ({ name: o.name, extra_cost: o.extra_cost })) ?? [], options: product.options?.map(o => ({ name: o.name, extra_cost: o.extra_cost })) ?? [],
ingredients: product.ingredients?.map(i => ({ name: i.name })) ?? [], ingredients: product.ingredients?.map(i => ({ name: i.name, extra_cost: i.extra_cost ?? 0 })) ?? [],
preference_sets: product.preference_sets?.map(ps => ({
name: ps.name,
choices: ps.choices.map(c => ({ name: c.name, extra_cost: c.extra_cost })),
})) ?? [],
}) })
const [imageFile, setImageFile] = useState(null)
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
function setField(k, v) { setForm(f => ({ ...f, [k]: v })) } function setField(k, v) { setForm(f => ({ ...f, [k]: v })) }
// Options
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0 }] })) } function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0 }] })) }
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) } function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
function setOption(i, k, v) { function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) }
setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) }))
}
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '' }] })) } // Ingredients
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0 }] })) }
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) } function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
function setIngredient(i, v) { function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { name: v } : ing) }))
// Preference sets
function addPrefSet() { setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', choices: [] }] })) }
function removePrefSet(si) { setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) })) }
function setPrefSetName(si, v) { setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, name: v } : ps) })) }
function addChoice(si) { setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0 }] } : ps) })) }
function removeChoice(si, ci) { setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, choices: ps.choices.filter((_, cidx) => cidx !== ci) } : ps) })) }
function setChoice(si, ci, k, v) {
setForm(f => ({
...f,
preference_sets: f.preference_sets.map((ps, idx) => idx === si
? { ...ps, choices: ps.choices.map((ch, cidx) => cidx === ci ? { ...ch, [k]: v } : ch) }
: ps
),
}))
} }
function submit() { async function submit() {
const body = { const body = {
...form, ...form,
category_id: form.category_id ? Number(form.category_id) : null, category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price), base_price: parseFloat(form.base_price),
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null, printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
options: form.options.map(o => ({ ...o, extra_cost: parseFloat(o.extra_cost) || 0 })),
ingredients: form.ingredients.map(i => ({ ...i, extra_cost: parseFloat(i.extra_cost) || 0 })),
preference_sets: form.preference_sets.map(ps => ({
...ps,
choices: ps.choices.map(c => ({ ...c, extra_cost: parseFloat(c.extra_cost) || 0 })),
})),
} }
onSave(body) onSave(body)
// Upload image after save if selected
if (imageFile && product.id) {
setUploading(true)
try {
const fd = new FormData()
fd.append('file', imageFile)
await client.post(`/api/products/${product.id}/image`, fd)
qc.invalidateQueries({ queryKey: ['products-all'] })
} catch {
toast.error('Σφάλμα ανεβάσματος εικόνας')
} finally {
setUploading(false)
}
}
} }
return ( return (
<div className="fixed inset-0 bg-black/40 flex justify-end z-50"> <div className="fixed inset-0 bg-black/40 flex justify-end z-50">
<div className="bg-white w-full max-w-md h-full overflow-y-auto shadow-xl p-6 space-y-5"> <div className="bg-white w-full max-w-lg h-full overflow-y-auto shadow-xl p-6 space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-bold text-gray-800 text-lg">{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}</h2> <h2 className="font-bold text-gray-800 text-lg">{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}</h2>
<button onClick={onClose} className="btn btn-ghost"></button> <button onClick={onClose} className="btn btn-ghost"></button>
</div> </div>
<div> <div><label className="label">Όνομα *</label><input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus /></div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus />
</div>
<div> <div>
<label className="label">Κατηγορία</label> <label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}> <select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
@@ -260,10 +337,7 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
</div> </div>
<div> <div><label className="label">Τιμή βάσης () *</label><input className="input" type="number" step="0.01" min="0" value={form.base_price} onChange={e => setField('base_price', e.target.value)} /></div>
<label className="label">Τιμή βάσης () *</label>
<input className="input" type="number" step="0.01" min="0" value={form.base_price} onChange={e => setField('base_price', e.target.value)} />
</div>
<div> <div>
<label className="label">Ζώνη εκτυπωτή</label> <label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}> <select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
@@ -276,40 +350,80 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
<span className="text-sm font-medium text-gray-700">Διαθέσιμο</span> <span className="text-sm font-medium text-gray-700">Διαθέσιμο</span>
</label> </label>
{/* Options */} {/* Image */}
<div> {product.id && (
<div className="flex items-center justify-between mb-2"> <div>
<label className="label mb-0">Επιλογές</label> <label className="label">Εικόνα (256×256)</label>
<button onClick={addOption} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Επιλογή</button> {product.image_url && <img src={`${import.meta.env.VITE_API_URL || ''}${product.image_url}`} className="w-16 h-16 rounded-lg object-cover mb-2" alt="" />}
<input type="file" accept="image/*" className="text-sm" onChange={e => setImageFile(e.target.files[0])} />
</div> </div>
)}
{/* Options */}
<Section title="Επιλογές" onAdd={addOption} addLabel="+ Επιλογή">
{form.options.map((opt, i) => ( {form.options.map((opt, i) => (
<div key={i} className="flex gap-2 mb-2"> <CostRow key={i} name={opt.name} cost={opt.extra_cost}
<input className="input flex-1" placeholder="Όνομα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} /> onName={v => setOption(i, 'name', v)} onCost={v => setOption(i, 'extra_cost', v)}
<input className="input w-24" type="number" step="0.01" placeholder="+€" value={opt.extra_cost} onChange={e => setOption(i, 'extra_cost', parseFloat(e.target.value) || 0)} /> onRemove={() => removeOption(i)} costLabel="+/- €" />
<button onClick={() => removeOption(i)} className="btn btn-danger px-2 min-h-0 h-10"></button>
</div>
))} ))}
</div> </Section>
{/* Ingredients */} {/* Ingredients */}
<div> <Section title="Υλικά (αφαιρούμενα)" onAdd={addIngredient} addLabel="+ Υλικό">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Υλικά</label>
<button onClick={addIngredient} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Υλικό</button>
</div>
{form.ingredients.map((ing, i) => ( {form.ingredients.map((ing, i) => (
<div key={i} className="flex gap-2 mb-2"> <CostRow key={i} name={ing.name} cost={ing.extra_cost}
<input className="input flex-1" placeholder="Υλικό" value={ing.name} onChange={e => setIngredient(i, e.target.value)} /> onName={v => setIngredient(i, 'name', v)} onCost={v => setIngredient(i, 'extra_cost', v)}
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-2 min-h-0 h-10"></button> onRemove={() => removeIngredient(i)} costLabel="+/- €" />
))}
</Section>
{/* Preference Sets */}
<Section title="Προτιμήσεις (αποκλειστική επιλογή)" onAdd={addPrefSet} addLabel="+ Σετ">
{form.preference_sets.map((ps, si) => (
<div key={si} className="border border-gray-200 rounded-xl p-3 mb-3 space-y-2">
<div className="flex gap-2">
<input className="input flex-1 text-sm" placeholder="Όνομα σετ π.χ. Ψήσιμο" value={ps.name} onChange={e => setPrefSetName(si, e.target.value)} />
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-2 min-h-0 h-9"></button>
</div>
{ps.choices.map((ch, ci) => (
<CostRow key={ci} name={ch.name} cost={ch.extra_cost}
onName={v => setChoice(si, ci, 'name', v)} onCost={v => setChoice(si, ci, 'extra_cost', v)}
onRemove={() => removeChoice(si, ci)} costLabel="+/- €" indent />
))}
<button onClick={() => addChoice(si)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7 ml-2">+ Επιλογή</button>
</div> </div>
))} ))}
</div> </Section>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={submit} disabled={!form.name.trim() || !form.base_price} className="flex-1 btn btn-primary">Αποθήκευση</button> <button onClick={submit} disabled={!form.name.trim() || !form.base_price || uploading} className="flex-1 btn btn-primary">
{uploading ? 'Ανέβασμα…' : 'Αποθήκευση'}
</button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
function Section({ title, onAdd, addLabel, children }) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="label mb-0 font-semibold">{title}</label>
<button onClick={onAdd} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">{addLabel}</button>
</div>
{children}
</div>
)
}
function CostRow({ name, cost, onName, onCost, onRemove, costLabel, indent }) {
return (
<div className={`flex gap-2 mb-2 ${indent ? 'ml-2' : ''}`}>
<input className="input flex-1 text-sm" placeholder="Όνομα" value={name} onChange={e => onName(e.target.value)} />
<input className="input w-24 text-sm" type="number" step="0.01" placeholder={costLabel} value={cost} onChange={e => onCost(e.target.value)} />
<button onClick={onRemove} className="btn btn-danger px-2 min-h-0 h-10 text-sm"></button>
</div>
)
}

View File

@@ -8,19 +8,45 @@ export default function TablesPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [addModal, setAddModal] = useState(false) const [addModal, setAddModal] = useState(false)
const [editModal, setEditModal] = useState(null) const [editModal, setEditModal] = useState(null)
const [confirmDeactivate, setConfirmDeactivate] = useState(null) const [batchModal, setBatchModal] = useState(null) // group id or null
const [form, setForm] = useState({ number: '', label: '' }) const [groupModal, setGroupModal] = useState(null) // null | {} | group object
const [confirmDelete, setConfirmDelete] = useState(null) // { id, hard }
const [showInactive, setShowInactive] = useState(false)
const { data: tables = [], isLoading } = useQuery({ const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables'], queryKey: ['tables-all', showInactive],
queryFn: () => client.get('/api/tables/').then(r => r.data), queryFn: () => client.get(`/api/tables/?include_inactive=${showInactive}`).then(r => r.data),
}) })
const invalidate = () => qc.invalidateQueries({ queryKey: ['tables'] }) const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['tables-all'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
// Next auto-increment number within a group (or global)
function nextNumber(groupId) {
const relevant = groupId
? tables.filter(t => t.group_id === groupId)
: tables
if (relevant.length === 0) return 1
return Math.max(...relevant.map(t => t.number)) + 1
}
const createTable = useMutation({ const createTable = useMutation({
mutationFn: (body) => client.post('/api/tables/', body), mutationFn: (body) => client.post('/api/tables/', body),
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); setForm({ number: '', label: '' }); invalidate() }, onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const batchCreate = useMutation({
mutationFn: (body) => client.post('/api/tables/batch', body),
onSuccess: (res) => { toast.success(`${res.data.length} τραπέζια δημιουργήθηκαν`); setBatchModal(null); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'), onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
}) })
@@ -30,97 +56,222 @@ export default function TablesPage() {
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
const deactivateTable = useMutation({ const deleteTable = useMutation({
mutationFn: (id) => client.delete(`/api/tables/${id}`), mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDeactivate(null); invalidate() }, onSuccess: (_, vars) => {
toast.success(vars.hard ? 'Διαγράφηκε' : 'Απενεργοποιήθηκε')
setConfirmDelete(null)
invalidate()
},
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const saveGroup = useMutation({
mutationFn: (body) => groupModal?.id
? client.put(`/api/tables/groups/${groupModal.id}`, body)
: client.post('/api/tables/groups', body),
onSuccess: () => { toast.success('Γκρουπ αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const deleteGroup = useMutation({
mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
onSuccess: () => { toast.success('Γκρουπ διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
// Group tables by group
const grouped = [
{ group: null, tables: tables.filter(t => !t.group_id) },
...groups.map(g => ({ group: g, tables: tables.filter(t => t.group_id === g.id) })),
].filter(section => section.tables.length > 0 || section.group)
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div> if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return ( return (
<div className="space-y-4 max-w-2xl"> <div className="space-y-6 max-w-3xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1> <h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button> <div className="flex gap-2 flex-wrap">
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" />
Εμφάνιση ανενεργών
</label>
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέο γκρουπ</button>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button>
</div>
</div> </div>
<div className="card divide-y divide-gray-100"> {grouped.map(({ group, tables: gt }) => (
{tables.length === 0 && ( <div key={group?.id ?? 'ungrouped'}>
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν τραπέζια.</p> {group && (
)} <div className="flex items-center gap-3 mb-2">
{tables.map(t => ( <h2 className="font-semibold text-gray-700">{group.name}</h2>
<div key={t.id} className="flex items-center gap-4 px-4 py-3"> <button onClick={() => setGroupModal(group)} className="text-xs text-gray-400 hover:text-gray-600"></button>
<span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span> <button onClick={() => setBatchModal(group.id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
<p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p> </div>
<button )}
onClick={() => { setEditModal(t); }} {!group && gt.length > 0 && <h2 className="font-semibold text-gray-500 mb-2 text-sm">Χωρίς γκρουπ</h2>}
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"
> <div className="card divide-y divide-gray-100">
Επεξεργασία {gt.length === 0 && (
</button> <p className="px-4 py-4 text-sm text-gray-400 text-center">Δεν υπάρχουν τραπέζια σε αυτό το γκρουπ.</p>
<button )}
onClick={() => setConfirmDeactivate(t.id)} {gt.map(t => (
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9" <div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-40' : ''}`}>
> <span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span>
Απενεργοποίηση <p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p>
</button> {!t.is_active && <span className="text-xs text-red-400 font-medium">Ανενεργό</span>}
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{t.is_active
? <button onClick={() => setConfirmDelete({ id: t.id, hard: false })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-amber-600 hover:bg-amber-50">Απενεργ.</button>
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
}
<button onClick={() => setConfirmDelete({ id: t.id, hard: true })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button>
</div>
))}
</div> </div>
))} </div>
</div> ))}
{/* Add table modal */} {tables.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν τραπέζια. Προσθέστε ένα.</p>
)}
{/* Add single table */}
{addModal && ( {addModal && (
<TableModal <TableModal
title="Νέο τραπέζι" title="Νέο τραπέζι"
form={form} initial={{ number: nextNumber(null), label: '', group_id: '' }}
setForm={setForm} groups={groups}
onSave={() => createTable.mutate({ number: Number(form.number), label: form.label || null })} onSave={(f) => createTable.mutate({ number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setAddModal(false)} onClose={() => setAddModal(false)}
/> />
)} )}
{/* Edit table modal */} {/* Edit table */}
{editModal && ( {editModal && (
<TableModal <TableModal
title="Επεξεργασία τραπεζιού" title="Επεξεργασία τραπεζιού"
form={{ number: editModal.number, label: editModal.label || '' }} initial={{ number: editModal.number, label: editModal.label || '', group_id: editModal.group_id || '' }}
setForm={(f) => setEditModal(t => ({ ...t, ...f }))} groups={groups}
onSave={() => updateTable.mutate({ id: editModal.id, number: Number(editModal.number), label: editModal.label || null })} onSave={(f) => updateTable.mutate({ id: editModal.id, number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setEditModal(null)} onClose={() => setEditModal(null)}
/> />
)} )}
{confirmDeactivate !== null && ( {/* Batch add */}
{batchModal !== null && (
<BatchModal
groupId={batchModal}
startNumber={nextNumber(batchModal)}
onSave={(body) => batchCreate.mutate(body)}
onClose={() => setBatchModal(null)}
/>
)}
{/* Group form */}
{groupModal !== null && (
<GroupModal
group={groupModal}
onSave={(name) => saveGroup.mutate({ name })}
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
onClose={() => setGroupModal(null)}
/>
)}
{/* Delete confirmation */}
{confirmDelete && (
<ConfirmModal <ConfirmModal
title="Απενεργοποίηση τραπεζιού;" title={confirmDelete.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'}
message="Το τραπέζι δεν θα εμφανίζεται πλέον." message={confirmDelete.hard
confirmLabel="Απενεργοποίηση" ? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'}
confirmLabel={confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
confirmClass="btn-danger" confirmClass="btn-danger"
onConfirm={() => deactivateTable.mutate(confirmDeactivate)} onConfirm={() => deleteTable.mutate(confirmDelete)}
onCancel={() => setConfirmDeactivate(null)} onCancel={() => setConfirmDelete(null)}
/> />
)} )}
</div> </div>
) )
} }
function TableModal({ title, form, setForm, onSave, onClose }) { function TableModal({ title, initial, groups, onSave, onClose }) {
const [form, setForm] = useState(initial)
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">{title}</h2> <h2 className="font-bold text-gray-800">{title}</h2>
<div> <div>
<label className="label">Αριθμός τραπεζιού *</label> <label className="label">Αριθμός τραπεζιού *</label>
<input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus /> <input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus />
</div> </div>
<div> <div>
<label className="label">Ετικέτα (προαιρετικό)</label> <label className="label">Ετικέτα</label>
<input className="input" placeholder="π.χ. Βεράντα 1" value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} /> <input className="input" placeholder="π.χ. Βεράντα 1" value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
</div> </div>
<div>
<label className="label">Γκρουπ</label>
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
<option value=""> Χωρίς γκρουπ </option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={onSave} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button> <button onClick={() => onSave(form)} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div>
</div>
</div>
)
}
function BatchModal({ groupId, startNumber, onSave, onClose }) {
const [count, setCount] = useState(5)
const [prefix, setPrefix] = useState('')
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Μαζική προσθήκη τραπεζιών</h2>
<div>
<label className="label">Πλήθος</label>
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} autoFocus />
</div>
<div>
<label className="label">Πρόθεμα ετικέτας</label>
<input className="input" placeholder="π.χ. Out- → Out-1, Out-2…" value={prefix} onChange={e => setPrefix(e.target.value)} />
</div>
<p className="text-xs text-gray-400">Ξεκινά από αριθμό {startNumber}, δημιουργεί {count} τραπέζια.</p>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave({ group_id: groupId, count, name_prefix: prefix, start_number: startNumber })}
disabled={count < 1 || !prefix.trim()}
className="flex-1 btn btn-primary"
>
Δημιουργία
</button>
</div>
</div>
</div>
)
}
function GroupModal({ group, onSave, onDelete, onClose }) {
const [name, setName] = useState(group.name || '')
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
<h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία γκρουπ' : 'Νέο γκρουπ'}</h2>
<div>
<label className="label">Όνομα γκρουπ</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
<div className="flex gap-3">
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">🗑</button>}
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave(name)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import useAuthStore from './store/authStore' import useAuthStore from './store/authStore'
import client from './api/client'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import TableListPage from './pages/TableListPage' import TableListPage from './pages/TableListPage'
import TableDetailPage from './pages/TableDetailPage' import TableDetailPage from './pages/TableDetailPage'
@@ -13,6 +14,19 @@ function ProtectedRoute({ children }) {
return children return children
} }
// Rehydrates user object from token on every app load
function AuthRehydrator() {
const { token, user, login, logout } = useAuthStore()
useEffect(() => {
if (token && !user) {
client.get('/api/auth/me')
.then(r => login(r.data, token))
.catch(() => logout())
}
}, [])
return null
}
function OfflineListener() { function OfflineListener() {
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -26,6 +40,7 @@ function OfflineListener() {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthRehydrator />
<OfflineListener /> <OfflineListener />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />

View File

@@ -15,6 +15,11 @@ client.interceptors.response.use(
err => { err => {
if (!err.response) { if (!err.response) {
window.dispatchEvent(new Event('backend-offline')) window.dispatchEvent(new Event('backend-offline'))
} else if (err.response.status === 401) {
// Token expired or user blocked — force logout
localStorage.removeItem('token')
localStorage.removeItem('savedUsername')
window.location.href = '/login'
} }
return Promise.reject(err) return Promise.reject(err)
} }

View File

@@ -8,12 +8,21 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const options = product.options || [] const options = product.options || []
const ingredients = product.ingredients || [] const ingredients = product.ingredients || []
const preferenceSets = product.preference_sets || []
const [selectedPreferences, setSelectedPreferences] = useState(
Object.fromEntries(preferenceSets.map(ps => [ps.id, null]))
)
function selectPreference(setId, choice) {
setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
}
function toggleOption(opt) { function toggleOption(opt) {
setSelectedOptions(prev => { setSelectedOptions(prev => {
const exists = prev.find(o => o.id === opt.id) const exists = prev.find(o => o.id === opt.id)
if (exists) return prev.filter(o => o.id !== opt.id) if (exists) return prev.filter(o => o.id !== opt.id)
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.price_delta }] return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
}) })
} }
@@ -23,11 +32,21 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
) )
} }
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta || 0), 0) const prefExtra = Object.values(selectedPreferences).reduce((s, ch) => s + (ch?.extra_cost ?? 0), 0)
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta ?? o.extra_cost ?? 0), 0) + prefExtra
const totalPrice = (product.base_price + extraPrice) * quantity const totalPrice = (product.base_price + extraPrice) * quantity
function handleAdd() { function handleAdd() {
onAdd({ product_id: product.id, quantity, selected_options: selectedOptions, removed_ingredients: removedIngredients, notes }) const prefChoices = Object.values(selectedPreferences)
.filter(Boolean)
.map(ch => ({ id: ch.id, name: ch.name, price_delta: ch.extra_cost ?? 0 }))
onAdd({
product_id: product.id,
quantity,
selected_options: [...selectedOptions, ...prefChoices],
removed_ingredients: removedIngredients,
notes,
})
onClose() onClose()
} }
@@ -49,12 +68,30 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
onChange={() => toggleOption(opt)} onChange={() => toggleOption(opt)}
/> />
<span>{opt.name}</span> <span>{opt.name}</span>
{opt.price_delta > 0 && <span className="option-price">+{Number(opt.price_delta).toFixed(2)} </span>} {(opt.extra_cost ?? 0) !== 0 && <span className="option-price">{(opt.extra_cost ?? 0) > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} </span>}
</label> </label>
))} ))}
</section> </section>
)} )}
{preferenceSets.map(ps => (
<section key={ps.id} className="modal-section">
<h3>{ps.name}</h3>
{ps.choices.map(ch => (
<label key={ch.id} className="modal-option">
<input
type="radio"
name={`pref-${ps.id}`}
checked={selectedPreferences[ps.id]?.id === ch.id}
onChange={() => selectPreference(ps.id, ch)}
/>
<span>{ch.name}</span>
{(ch.extra_cost ?? 0) !== 0 && <span className="option-price">{(ch.extra_cost ?? 0) > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} </span>}
</label>
))}
</section>
))}
{ingredients.length > 0 && ( {ingredients.length > 0 && (
<section className="modal-section"> <section className="modal-section">
<h3>Αφαίρεση υλικών</h3> <h3>Αφαίρεση υλικών</h3>