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:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user