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/license_state.json:/app/license_state.json
- ./logo.png:/app/logo.png:ro
- ./data/product_images:/app/data/product_images
extra_hosts:
- "host.docker.internal:host-gateway"

View File

@@ -1,6 +1,8 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from database import engine, Base
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
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
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
_run_migrations()
sync_task = await start_cloud_sync()
yield
sync_task.cancel()
@@ -34,6 +61,11 @@ app.add_middleware(
)
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(tables.router, prefix="/api/tables", tags=["tables"])
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)
is_available = Column(Boolean, default=True, nullable=False)
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
image_url = Column(String, nullable=True)
category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products")
options = relationship("ProductOption", 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")
@@ -48,5 +50,28 @@ class ProductIngredient(Base):
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
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 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):
__tablename__ = "tables"
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)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
floor_x = Column(Float, nullable=True)
floor_y = Column(Float, nullable=True)
group = relationship("TableGroup", back_populates="tables")
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):
_blacklisted_tokens.add(token)
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()
if not product or not product.is_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(
order_id=order_id,
product_id=item_in.product_id,
added_by=user.id,
quantity=item_in.quantity,
unit_price=product.base_price, # price snapshot
selected_options=json.dumps(item_in.selected_options) if item_in.selected_options else None,
unit_price=product.base_price + extra_cost, # price snapshot with options
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,
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 typing import List
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 schemas.product import (
ProductCreate, ProductUpdate, ProductOut,
CategoryCreate, CategoryUpdate, CategoryOut,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate,
)
from routers.deps import get_current_user, require_manager
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])
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)
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.commit()
db.refresh(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)
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()
@@ -54,13 +97,16 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
# ── Products ──────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[ProductOut])
def list_products(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Product).filter(Product.is_available == True).all()
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
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)
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)
db.add(product)
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()))
for ing in body.ingredients:
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.refresh(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()
if not product:
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)
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.refresh(product)
return product

View File

@@ -3,24 +3,72 @@ from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.table import Table
from models.table import Table, TableGroup
from models.order import Order
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
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])
def list_tables(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Table).filter(Table.is_active == True).all()
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
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)
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())
db.add(table)
db.commit()
@@ -28,6 +76,28 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
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)
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()
@@ -41,10 +111,19 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
@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()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
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()

View File

@@ -3,11 +3,18 @@ from datetime import datetime
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):
product_id: int
quantity: int
selected_options: Optional[List[int]] = None
removed_ingredients: Optional[List[int]] = None
selected_options: Optional[List[SelectedOptionInput]] = None
removed_ingredients: Optional[List[str]] = None
notes: Optional[str] = None

View File

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

View File

@@ -1,10 +1,27 @@
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):
number: int
label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True
@@ -12,9 +29,17 @@ class TableCreate(TableBase):
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):
number: Optional[int] = None
label: Optional[str] = None
group_id: Optional[int] = None
is_active: Optional[bool] = None
@@ -27,5 +52,6 @@ class TableOut(TableBase):
id: int
floor_x: Optional[float] = None
floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None
model_config = {"from_attributes": True}

View File

@@ -4,30 +4,54 @@ import toast from 'react-hot-toast'
import client from '../api/client'
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() {
const qc = useQueryClient()
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 [confirmDelete, setConfirmDelete] = useState(null) // { type: 'product'|'category', id }
const [confirmDelete, setConfirmDelete] = useState(null)
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
})
// Manager fetches ALL products including unavailable ones
const { data: allProducts = [] } = useQuery({
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({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/status').then(r => r.data.printers ?? []),
const { data: statusData } = useQuery({
queryKey: ['system-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data),
staleTime: 60_000,
})
const printers = statusData?.printers ?? []
const products = selectedCat
? allProducts.filter(p => p.category_id === selectedCat)
@@ -39,8 +63,7 @@ export default function ProductsPage() {
}
const saveCat = useMutation({
mutationFn: (body) =>
editCat?.id
mutationFn: (body) => editCat?.id
? client.put(`/api/products/categories/${editCat.id}`, body)
: client.post('/api/products/categories', body),
onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() },
@@ -53,9 +76,13 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'),
})
const reorderCats = useMutation({
mutationFn: (items) => client.put('/api/products/categories/reorder', items),
onSuccess: () => invalidate(),
})
const saveProduct = useMutation({
mutationFn: (body) =>
editProduct?.id
mutationFn: (body) => editProduct?.id
? client.put(`/api/products/${editProduct.id}`, body)
: client.post('/api/products/', body),
onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() },
@@ -64,7 +91,7 @@ export default function ProductsPage() {
const toggleAvail = useMutation({
mutationFn: ({ id, is_available }) => client.put(`/api/products/${id}`, { is_available }),
onSuccess: () => { invalidate() },
onSuccess: () => invalidate(),
onError: () => toast.error('Σφάλμα'),
})
@@ -74,6 +101,19 @@ export default function ProductsPage() {
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() {
if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
@@ -83,7 +123,7 @@ export default function ProductsPage() {
return (
<div className="flex gap-6 h-full">
{/* 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">
<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>
@@ -94,16 +134,16 @@ export default function ProductsPage() {
>
Όλα
</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'}`}>
<button
onClick={() => setSelectedCat(cat.id)}
className="flex-1 text-left px-3 py-2.5 text-sm font-medium"
>
{cat.color && <span className="w-2.5 h-2.5 rounded-full ml-2 shrink-0" style={{ background: cat.color }} />}
<button onClick={() => setSelectedCat(cat.id)} className="flex-1 text-left px-2 py-2.5 text-sm font-medium truncate">
{cat.name}
</button>
<button onClick={() => setEditCat(cat)} className="p-1.5 rounded-lg hover:bg-black/10 text-xs"></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 === 0} className="p-1 text-xs disabled:opacity-30"></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>
))}
</aside>
@@ -123,41 +163,39 @@ export default function ProductsPage() {
<div className="space-y-3">
{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">
<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">
{categories.find(c => c.id === p.category_id)?.name ?? '—'} ·
{p.base_price.toFixed(2)}
{categories.find(c => c.id === p.category_id)?.name ?? '—'} · {p.base_price.toFixed(2)}
{p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`}
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<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"
/>
<label className="flex items-center gap-2 cursor-pointer select-none shrink-0">
<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" />
<span className="text-sm text-gray-600">Διαθέσιμο</span>
</label>
<button onClick={() => setEditProduct(p)} className="btn btn-secondary 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">Διαγραφή</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 shrink-0">Απενεργ.</button>
</div>
))}
</div>
</div>
{/* Category form modal */}
{editCat !== null && (
<CategoryFormModal
cat={editCat}
onSave={(name, sort_order) => saveCat.mutate({ name, sort_order: Number(sort_order) })}
onSave={(name, color) => saveCat.mutate({ name, color })}
onClose={() => setEditCat(null)}
/>
)}
{/* Product form panel */}
{editProduct !== null && (
<ProductFormPanel
product={editProduct}
@@ -170,9 +208,9 @@ export default function ProductsPage() {
{confirmDelete && (
<ConfirmModal
title="Επιβεβαίωση διαγραφής"
message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί.'}
confirmLabel="Διαγραφή"
title="Επιβεβαίωση"
message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί (δεν θα εμφανίζεται στους σερβιτόρους).'}
confirmLabel="Επιβεβαίωση"
confirmClass="btn-danger"
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmDelete(null)}
@@ -184,7 +222,7 @@ export default function ProductsPage() {
function CategoryFormModal({ cat, onSave, onClose }) {
const [name, setName] = useState(cat.name || '')
const [sort, setSort] = useState(cat.sort_order ?? 0)
const [color, setColor] = useState(cat.color || '')
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">
@@ -194,12 +232,12 @@ function CategoryFormModal({ cat, onSave, onClose }) {
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
<div>
<label className="label">Σειρά ταξινόμησης</label>
<input className="input" type="number" value={sort} onChange={e => setSort(e.target.value)} />
<label className="label">Χρώμα</label>
<ColorPicker value={color} onChange={setColor} />
</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={() => 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>
@@ -214,45 +252,84 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
is_available: product.is_available ?? true,
printer_zone_id: product.printer_zone_id || '',
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 })) }
// Options
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 setOption(i, k, v) {
setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) }))
}
function setOption(i, k, v) { 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 setIngredient(i, v) {
setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { name: v } : ing) }))
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: 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 = {
...form,
category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price),
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)
// 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 (
<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">
<h2 className="font-bold text-gray-800 text-lg">{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}</h2>
<button onClick={onClose} className="btn btn-ghost"></button>
</div>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus />
</div>
<div><label className="label">Όνομα *</label><input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus /></div>
<div>
<label className="label">Κατηγορία</label>
<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>)}
</select>
</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>
<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>
<label className="label">Ζώνη εκτυπωτή</label>
<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>
</label>
{/* Options */}
{/* Image */}
{product.id && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Επιλογές</label>
<button onClick={addOption} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Επιλογή</button>
<label className="label">Εικόνα (256×256)</label>
{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>
)}
{/* Options */}
<Section title="Επιλογές" onAdd={addOption} addLabel="+ Επιλογή">
{form.options.map((opt, i) => (
<div key={i} className="flex gap-2 mb-2">
<input className="input flex-1" placeholder="Όνομα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
<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)} />
<button onClick={() => removeOption(i)} className="btn btn-danger px-2 min-h-0 h-10"></button>
</div>
<CostRow key={i} name={opt.name} cost={opt.extra_cost}
onName={v => setOption(i, 'name', v)} onCost={v => setOption(i, 'extra_cost', v)}
onRemove={() => removeOption(i)} costLabel="+/- €" />
))}
</div>
</Section>
{/* Ingredients */}
<div>
<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>
<Section title="Υλικά (αφαιρούμενα)" onAdd={addIngredient} addLabel="+ Υλικό">
{form.ingredients.map((ing, i) => (
<div key={i} className="flex gap-2 mb-2">
<input className="input flex-1" placeholder="Υλικό" value={ing.name} onChange={e => setIngredient(i, e.target.value)} />
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-2 min-h-0 h-10"></button>
<CostRow key={i} name={ing.name} cost={ing.extra_cost}
onName={v => setIngredient(i, 'name', v)} onCost={v => setIngredient(i, 'extra_cost', v)}
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>
</Section>
<div className="flex gap-3 pt-2">
<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>
)
}
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 [addModal, setAddModal] = useState(false)
const [editModal, setEditModal] = useState(null)
const [confirmDeactivate, setConfirmDeactivate] = useState(null)
const [form, setForm] = useState({ number: '', label: '' })
const [batchModal, setBatchModal] = useState(null) // group id or null
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({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
queryKey: ['tables-all', showInactive],
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({
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 || 'Σφάλμα'),
})
@@ -30,97 +56,222 @@ export default function TablesPage() {
onError: () => toast.error('Σφάλμα'),
})
const deactivateTable = useMutation({
mutationFn: (id) => client.delete(`/api/tables/${id}`),
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDeactivate(null); invalidate() },
const deleteTable = useMutation({
mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
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('Σφάλμα'),
})
// 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>
return (
<div className="space-y-4 max-w-2xl">
<div className="flex items-center justify-between">
<div className="space-y-6 max-w-3xl">
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1>
<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>
{grouped.map(({ group, tables: gt }) => (
<div key={group?.id ?? 'ungrouped'}>
{group && (
<div className="flex items-center gap-3 mb-2">
<h2 className="font-semibold text-gray-700">{group.name}</h2>
<button onClick={() => setGroupModal(group)} className="text-xs text-gray-400 hover:text-gray-600"></button>
<button onClick={() => setBatchModal(group.id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
</div>
)}
{!group && gt.length > 0 && <h2 className="font-semibold text-gray-500 mb-2 text-sm">Χωρίς γκρουπ</h2>}
<div className="card divide-y divide-gray-100">
{tables.length === 0 && (
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν τραπέζια.</p>
{gt.length === 0 && (
<p className="px-4 py-4 text-sm text-gray-400 text-center">Δεν υπάρχουν τραπέζια σε αυτό το γκρουπ.</p>
)}
{tables.map(t => (
<div key={t.id} className="flex items-center gap-4 px-4 py-3">
{gt.map(t => (
<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
onClick={() => { setEditModal(t); }}
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"
>
Επεξεργασία
</button>
<button
onClick={() => setConfirmDeactivate(t.id)}
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9"
>
Απενεργοποίηση
</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>
))}
{/* Add table modal */}
{tables.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν τραπέζια. Προσθέστε ένα.</p>
)}
{/* Add single table */}
{addModal && (
<TableModal
title="Νέο τραπέζι"
form={form}
setForm={setForm}
onSave={() => createTable.mutate({ number: Number(form.number), label: form.label || null })}
initial={{ number: nextNumber(null), label: '', group_id: '' }}
groups={groups}
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)}
/>
)}
{/* Edit table modal */}
{/* Edit table */}
{editModal && (
<TableModal
title="Επεξεργασία τραπεζιού"
form={{ number: editModal.number, label: editModal.label || '' }}
setForm={(f) => setEditModal(t => ({ ...t, ...f }))}
onSave={() => updateTable.mutate({ id: editModal.id, number: Number(editModal.number), label: editModal.label || null })}
initial={{ number: editModal.number, label: editModal.label || '', group_id: editModal.group_id || '' }}
groups={groups}
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)}
/>
)}
{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
title="Απενεργοποίηση τραπεζιού;"
message="Το τραπέζι δεν θα εμφανίζεται πλέον."
confirmLabel="Απενεργοποίηση"
title={confirmDelete.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'}
message={confirmDelete.hard
? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'}
confirmLabel={confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
confirmClass="btn-danger"
onConfirm={() => deactivateTable.mutate(confirmDeactivate)}
onCancel={() => setConfirmDeactivate(null)}
onConfirm={() => deleteTable.mutate(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
)
}
function TableModal({ title, form, setForm, onSave, onClose }) {
function TableModal({ title, initial, groups, onSave, onClose }) {
const [form, setForm] = useState(initial)
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">
<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>
<div>
<label className="label">Αριθμός τραπεζιού *</label>
<input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus />
</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 }))} />
</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">
<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>

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import useAuthStore from './store/authStore'
import client from './api/client'
import LoginPage from './pages/LoginPage'
import TableListPage from './pages/TableListPage'
import TableDetailPage from './pages/TableDetailPage'
@@ -13,6 +14,19 @@ function ProtectedRoute({ 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() {
const navigate = useNavigate()
useEffect(() => {
@@ -26,6 +40,7 @@ function OfflineListener() {
export default function App() {
return (
<BrowserRouter>
<AuthRehydrator />
<OfflineListener />
<Routes>
<Route path="/login" element={<LoginPage />} />

View File

@@ -15,6 +15,11 @@ client.interceptors.response.use(
err => {
if (!err.response) {
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)
}

View File

@@ -8,12 +8,21 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const options = product.options || []
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) {
setSelectedOptions(prev => {
const exists = prev.find(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
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()
}
@@ -49,12 +68,30 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
onChange={() => toggleOption(opt)}
/>
<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>
))}
</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 && (
<section className="modal-section">
<h3>Αφαίρεση υλικών</h3>