Compare commits

...

3 Commits

Author SHA1 Message Date
5de89a722c feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new
  DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal,
  PaymentMethodModal; updated Sidebar routing and App navigation
- Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals
- Backend routers: extended orders, reports, shifts, products, business_day endpoints;
  updated cloud_sync service
- Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated
  TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:24:54 +03:00
aa92623802 chore: set initial version to 0.1.0 (beta)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:49:02 +03:00
0d21b7f20b fix: deployment readiness — correct registry/cloud URLs, fix install.sh
- .env.example: set REGISTRY=registry.bonamin.gr, CLOUD_URL=https://xenia-admin.bonamin.gr, DATA_PATH=/opt/xenia/data
- install.sh: auto-create .env from example, prompt for SITE_ID/SITE_KEY/SECRET_KEY,
  clarify DNS subdomain requirements, add backend API proxy block to nginx config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:36:43 +03:00
42 changed files with 2004 additions and 1202 deletions

View File

@@ -1,14 +1,14 @@
# Registry # Registry
REGISTRY=registry.yourdomain.com REGISTRY=registry.bonamin.gr
VERSION=1.0.0 VERSION=0.1.0
# Backend runtime secrets (get SITE_ID and SITE_KEY from sysadmin panel) # Backend runtime secrets (get SITE_ID and SITE_KEY from the sysadmin panel)
SITE_ID=your-site-id SITE_ID=your-site-id
SITE_KEY=your-site-key SITE_KEY=your-site-key
CLOUD_URL=https://api.yourdomain.com CLOUD_URL=https://xenia-admin.bonamin.gr
SECRET_KEY=generate-with-openssl-rand-hex-32 SECRET_KEY=generate-with-openssl-rand-hex-32
LICENSE_GRACE_HOURS=24 LICENSE_GRACE_HOURS=24
# Volumes — absolute paths recommended on client machines # Volumes — absolute paths on the client machine
DATA_PATH=/home/user/appdata/pos/data DATA_PATH=/opt/xenia/data
LOGO_PATH=/home/user/appdata/pos/logo.png LOGO_PATH=/opt/xenia/logo.png

View File

@@ -10,6 +10,7 @@ services:
- LICENSE_GRACE_HOURS=${LICENSE_GRACE_HOURS:-24} - LICENSE_GRACE_HOURS=${LICENSE_GRACE_HOURS:-24}
- DATABASE_URL=sqlite:////app/data/pos.db - DATABASE_URL=sqlite:////app/data/pos.db
- VERSION=${VERSION:-0.0.0} - VERSION=${VERSION:-0.0.0}
- HOST_IP=${HOST_IP:-}
volumes: volumes:
- ${DATA_PATH}:/app/data - ${DATA_PATH}:/app/data
- ${LOGO_PATH}:/app/logo.png:ro - ${LOGO_PATH}:/app/logo.png:ro

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Xenia POS — first-time install script # Xenia POS — first-time install script
# Run this on the server machine before starting the stack. # Run this on the client machine before starting the stack.
# Usage: bash install.sh # Usage: bash install.sh
set -e set -e
@@ -11,13 +11,48 @@ echo "=== Xenia POS Install ==="
echo "" echo ""
# ── 1. Create required directories ─────────────────────────────────────────── # ── 1. Create required directories ───────────────────────────────────────────
echo "[ 1/4 ] Creating directories..." echo "[ 1/5 ] Creating directories..."
mkdir -p "$SCRIPT_DIR/data" mkdir -p "$SCRIPT_DIR/data"
mkdir -p "$SCRIPT_DIR/certs" mkdir -p "$SCRIPT_DIR/certs"
mkdir -p "$SCRIPT_DIR/nginx-proxy" mkdir -p "$SCRIPT_DIR/nginx-proxy"
mkdir -p /opt/xenia/data
touch /opt/xenia/logo.png 2>/dev/null || true
# ── 2. Write nginx-proxy/nginx.conf ────────────────────────────────────────── # ── 2. Create .env from .env.example if missing ───────────────────────────────
echo "[ 2/4 ] Writing nginx proxy config..." echo "[ 2/5 ] Configuring environment..."
if [ ! -f "$SCRIPT_DIR/.env" ]; then
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
echo ""
echo " A .env file has been created from .env.example."
echo " You must fill in SITE_ID, SITE_KEY, and SECRET_KEY before starting."
echo ""
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.gr"
echo " Generate SECRET_KEY with: openssl rand -hex 32"
echo ""
read -rp " Enter SITE_ID: " INPUT_SITE_ID
read -rp " Enter SITE_KEY: " INPUT_SITE_KEY
read -rp " Enter SECRET_KEY (leave blank to auto-generate): " INPUT_SECRET_KEY
if [ -z "$INPUT_SECRET_KEY" ]; then
INPUT_SECRET_KEY=$(openssl rand -hex 32)
echo " Generated SECRET_KEY: $INPUT_SECRET_KEY"
fi
sed -i "s/^SITE_ID=.*/SITE_ID=${INPUT_SITE_ID}/" "$SCRIPT_DIR/.env"
sed -i "s/^SITE_KEY=.*/SITE_KEY=${INPUT_SITE_KEY}/" "$SCRIPT_DIR/.env"
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${INPUT_SECRET_KEY}/" "$SCRIPT_DIR/.env"
echo ""
echo " .env written. Review it at: $SCRIPT_DIR/.env"
echo ""
else
echo " .env already exists — skipping."
fi
# ── 3. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
echo "[ 3/5 ] Writing nginx proxy config..."
cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF' cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF'
server { server {
listen 80; listen 80;
@@ -26,7 +61,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
server_name _; server_name waiter.*;
ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem; ssl_certificate_key /etc/nginx/certs/key.pem;
@@ -43,8 +78,8 @@ server {
} }
server { server {
listen 4443 ssl; listen 443 ssl;
server_name _; server_name manager.*;
ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem; ssl_certificate_key /etc/nginx/certs/key.pem;
@@ -59,39 +94,69 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
server {
listen 443 ssl default_server;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://waiter_pwa:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF EOF
# ── 3. SSL certificates ─────────────────────────────────────────────────────── # ── 4. SSL certificates ───────────────────────────────────────────────────────
echo "[ 3/4 ] Setting up SSL certificates..." echo "[ 4/5 ] Checking SSL certificates..."
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
echo " Certificates already exist — skipping." echo " Certificates already exist — skipping."
else else
echo "" echo ""
echo " No certificates found in certs/" echo " No certificates found in certs/"
echo " You need a cert for your domain (e.g. xeniapos.yourdomain.com)."
echo "" echo ""
echo " Option A — Let's Encrypt (recommended for production):" echo " DNS requirement:"
echo " Two subdomains must point to this machine's IP:"
echo " waiter.YOURDOMAIN → this machine's IP"
echo " manager.YOURDOMAIN → this machine's IP"
echo " The waiter domain should also be registered in the sysadmin"
echo " panel as the 'Waiter Domain' so phones get the QR code."
echo ""
echo " Option A — Let's Encrypt (recommended):"
echo " sudo apt install certbot" echo " sudo apt install certbot"
echo " sudo certbot certonly --manual --preferred-challenges dns -d YOUR_DOMAIN" echo " sudo certbot certonly --manual --preferred-challenges dns \\"
echo " sudo cp /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem certs/cert.pem" echo " -d waiter.YOURDOMAIN -d manager.YOURDOMAIN"
echo " sudo cp /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem certs/key.pem" echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/fullchain.pem certs/cert.pem"
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/privkey.pem certs/key.pem"
echo "" echo ""
echo " Option B — Self-signed (local testing only, requires CA install on each device):" echo " Option B — Self-signed / mkcert (local testing only):"
echo " sudo apt install mkcert libnss3-tools" echo " sudo apt install mkcert libnss3-tools"
echo " mkcert -install" echo " mkcert -install"
echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem YOUR_IP localhost" echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem \\"
echo " waiter.YOURDOMAIN manager.YOURDOMAIN"
echo "" echo ""
echo " Add certs then re-run this script, or run: docker compose up -d" echo " Add certs then run: docker compose up -d"
echo "" echo ""
fi fi
# ── 4. Create placeholder logo if missing ──────────────────────────────────── # ── 5. Logo ───────────────────────────────────────────────────────────────────
echo "[ 4/4 ] Checking logo..." echo "[ 5/5 ] Checking logo..."
if [ ! -f "$SCRIPT_DIR/logo.png" ]; then if [ ! -s "$SCRIPT_DIR/logo.png" ]; then
echo " WARNING: logo.png not found." echo " WARNING: logo.png not found or is empty."
echo " Place your logo at: $SCRIPT_DIR/logo.png" echo " Place your restaurant logo at: $SCRIPT_DIR/logo.png"
echo " Creating placeholder so the stack can start..."
touch "$SCRIPT_DIR/logo.png" touch "$SCRIPT_DIR/logo.png"
fi fi
@@ -104,7 +169,9 @@ if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; th
echo "Starting stack..." echo "Starting stack..."
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
echo "" echo ""
echo "Done! Services are running." echo "Done! Services running."
echo " Waiter app: https://waiter.YOURDOMAIN"
echo " Manager app: https://manager.YOURDOMAIN"
else else
echo "Add SSL certificates to certs/ then run:" echo "Add SSL certificates to certs/ then run:"
echo " docker compose up -d" echo " docker compose up -d"

View File

@@ -162,6 +162,33 @@ def close_business_day(
return day return day
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_business_day(
day_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a business day. Only allowed when it has no shifts."""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status == "open":
raise HTTPException(status_code=400, detail="Cannot delete an open business day — close it first")
shift_count = db.query(WaiterShift).filter(WaiterShift.business_day_id == day_id).count()
if shift_count > 0:
raise HTTPException(
status_code=409,
detail=f"Δεν είναι δυνατή η διαγραφή: η εργάσιμη μέρα έχει {shift_count} βάρδια/ες. Διαγράψτε πρώτα όλες τις βάρδιες.",
)
db.query(Order).filter(Order.business_day_id == day_id).update(
{"business_day_id": None}, synchronize_session=False
)
db.delete(day)
db.commit()
@router.get("/history") @router.get("/history")
def business_day_history( def business_day_history(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -120,14 +120,83 @@ def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_c
] ]
@router.get("/{order_id}", response_model=OrderOut) @router.get("/{order_id}")
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
order = db.query(Order).filter(Order.id == order_id).first() order = db.query(Order).filter(Order.id == order_id).first()
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db): if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
return order
# Resolve all user IDs referenced by this order in one query
user_ids = set()
user_ids.add(order.opened_by)
if order.closed_by:
user_ids.add(order.closed_by)
for item in order.items:
user_ids.add(item.added_by)
if item.paid_by:
user_ids.add(item.paid_by)
for log in order.audit_logs:
if log.waiter_id:
user_ids.add(log.waiter_id)
users = db.query(User).filter(User.id.in_(user_ids)).all()
name_map = {u.id: u.nickname or u.full_name or u.username for u in users}
def fmt_item(i):
return {
"id": i.id,
"order_id": i.order_id,
"product_id": i.product_id,
"product": {"id": i.product.id, "name": i.product.name} if i.product else None,
"added_by": i.added_by,
"added_by_name": name_map.get(i.added_by),
"quantity": i.quantity,
"unit_price": i.unit_price,
"selected_options": i.selected_options,
"removed_ingredients": i.removed_ingredients,
"notes": i.notes,
"status": i.status,
"added_at": i.added_at,
"printed": i.printed,
"paid_by": i.paid_by,
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
"paid_at": i.paid_at,
"payment_method": i.payment_method,
"paid_in_shift_id": i.paid_in_shift_id,
}
def fmt_log(l):
return {
"id": l.id,
"order_id": l.order_id,
"event_type": l.event_type,
"waiter_id": l.waiter_id,
"waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None,
"item_ids": l.item_ids,
"amount": l.amount,
"payment_method": l.payment_method,
"note": l.note,
"created_at": l.created_at,
"offline_at": l.offline_at,
"is_duplicate": l.is_duplicate,
}
return {
"id": order.id,
"table_id": order.table_id,
"opened_by": order.opened_by,
"opened_at": order.opened_at,
"status": order.status,
"closed_at": order.closed_at,
"closed_by": order.closed_by,
"notes": order.notes,
"business_day_id": order.business_day_id,
"items": [fmt_item(i) for i in order.items],
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
"audit_logs": [fmt_log(l) for l in order.audit_logs],
}
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
@@ -310,10 +379,26 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
if order.status not in ("paid", "open", "partially_paid"): if order.status not in ("paid", "open", "partially_paid"):
raise HTTPException(status_code=400, detail="Cannot close order in current status") raise HTTPException(status_code=400, detail="Cannot close order in current status")
now = datetime.now(timezone.utc)
# Mark all still-active items as 'closed' — unpaid, closed by manager
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
closed_item_ids = []
for item in active_items:
item.status = "closed"
closed_item_ids.append(item.id)
order.status = "closed" order.status = "closed"
order.closed_at = datetime.now(timezone.utc) order.closed_at = now
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id,
item_ids=closed_item_ids if closed_item_ids else None, note=note)
db.commit() db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
return {"status": "closed"} return {"status": "closed"}
@@ -419,10 +504,26 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
order = db.query(Order).filter(Order.id == order_id).first() order = db.query(Order).filter(Order.id == order_id).first()
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
now = datetime.now(timezone.utc)
# Cancel all still-active items
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
cancelled_item_ids = []
for item in active_items:
item.status = "cancelled"
cancelled_item_ids.append(item.id)
order.status = "cancelled" order.status = "cancelled"
order.closed_at = datetime.now(timezone.utc) order.closed_at = now
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
note = f"Ακύρωση από manager — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit() db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})

View File

@@ -17,9 +17,14 @@ from schemas.product import (
CategoryReparentRequest, CategoryReparentRequest,
) )
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter() router = APIRouter()
def _broadcast_products_changed():
broadcast_sync("products_changed", {})
IMAGE_DIR = "/app/data/product_images" IMAGE_DIR = "/app/data/product_images"
@@ -118,6 +123,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
db.add(cat) db.add(cat)
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
_broadcast_products_changed()
return cat return cat
@@ -128,6 +134,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
if cat: if cat:
cat.sort_order = item.sort_order cat.sort_order = item.sort_order
db.commit() db.commit()
_broadcast_products_changed()
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT) @router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
@@ -138,6 +145,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
if cat: if cat:
cat.sort_order = item.sort_order cat.sort_order = item.sort_order
db.commit() db.commit()
_broadcast_products_changed()
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT) @router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
@@ -148,6 +156,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
if cat: if cat:
cat.general_sort_order = item.general_sort_order cat.general_sort_order = item.general_sort_order
db.commit() db.commit()
_broadcast_products_changed()
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut) @router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
@@ -176,6 +185,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
cat.sort_order = sibling_count cat.sort_order = sibling_count
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
_broadcast_products_changed()
return cat return cat
@@ -188,6 +198,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
setattr(cat, field, value) setattr(cat, field, value)
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
_broadcast_products_changed()
return cat return cat
@@ -198,6 +209,7 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
raise HTTPException(status_code=404, detail="Category not found") raise HTTPException(status_code=404, detail="Category not found")
db.delete(cat) db.delete(cat)
db.commit() db.commit()
_broadcast_products_changed()
# ── Products ────────────────────────────────────────────────────────────────── # ── Products ──────────────────────────────────────────────────────────────────
@@ -218,6 +230,7 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
if product: if product:
product.sort_order = item.sort_order product.sort_order = item.sort_order
db.commit() db.commit()
_broadcast_products_changed()
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
@@ -255,6 +268,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
_replace_preference_sets(db, product, body.preference_sets) _replace_preference_sets(db, product, body.preference_sets)
db.commit() db.commit()
db.refresh(product) db.refresh(product)
_broadcast_products_changed()
return product return product
@@ -275,6 +289,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
_replace_preference_sets(db, product, body.preference_sets) _replace_preference_sets(db, product, body.preference_sets)
db.commit() db.commit()
db.refresh(product) db.refresh(product)
_broadcast_products_changed()
return product return product
@@ -305,6 +320,7 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
product.image_url = f"/static/product_images/{filename}" product.image_url = f"/static/product_images/{filename}"
db.commit() db.commit()
db.refresh(product) db.refresh(product)
_broadcast_products_changed()
return product return product
@@ -329,3 +345,4 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
else: else:
db.delete(product) db.delete(product)
db.commit() db.commit()
_broadcast_products_changed()

View File

@@ -178,7 +178,7 @@ def shift_orders_summary(
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@router.get("/orders/history", response_model=List[OrderOut]) @router.get("/orders/history")
def order_history( def order_history(
from_date: Optional[str] = Query(default=None, alias="from"), from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"), to_date: Optional[str] = Query(default=None, alias="to"),
@@ -191,7 +191,14 @@ def order_history(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_manager), user: User = Depends(require_manager),
): ):
q = db.query(Order) from sqlalchemy.orm import joinedload
from models.table import Table as TableModel
q = db.query(Order).options(
joinedload(Order.items).joinedload(OrderItem.product),
joinedload(Order.waiters),
joinedload(Order.audit_logs),
)
if business_day_id: if business_day_id:
q = q.filter(Order.business_day_id == business_day_id) q = q.filter(Order.business_day_id == business_day_id)
elif from_date or to_date: elif from_date or to_date:
@@ -205,7 +212,109 @@ def order_history(
q = q.filter(Order.status == order_status) q = q.filter(Order.status == order_status)
if table_id: if table_id:
q = q.filter(Order.table_id == table_id) q = q.filter(Order.table_id == table_id)
return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() orders = q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Collect all waiter IDs and table IDs to resolve in bulk
waiter_ids: set[int] = set()
table_ids: set[int] = set()
item_waiter_ids: set[int] = set()
for o in orders:
if o.opened_by:
waiter_ids.add(o.opened_by)
if o.closed_by:
waiter_ids.add(o.closed_by)
if o.table_id:
table_ids.add(o.table_id)
for item in o.items:
if item.added_by:
item_waiter_ids.add(item.added_by)
if item.paid_by:
item_waiter_ids.add(item.paid_by)
for log in o.audit_logs:
if log.waiter_id:
waiter_ids.add(log.waiter_id)
all_waiter_ids = waiter_ids | item_waiter_ids
waiters_map: dict[int, str] = {}
if all_waiter_ids:
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
waiters_map[w.id] = w.full_name or w.username
tables_map: dict[int, str] = {}
if table_ids:
for t in db.query(TableModel).filter(TableModel.id.in_(table_ids)).all():
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
def _wname(wid):
if wid is None:
return None
return waiters_map.get(wid, f"#{wid}")
def _dt_local(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
result = []
for o in orders:
items_out = []
for item in o.items:
items_out.append({
"id": item.id,
"order_id": item.order_id,
"product_id": item.product_id,
"product": {"id": item.product.id, "name": item.product.name} if item.product else None,
"added_by": item.added_by,
"added_by_name": _wname(item.added_by),
"added_at": _dt_local(item.added_at),
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"status": item.status,
"paid_by": item.paid_by,
"paid_by_name": _wname(item.paid_by),
"paid_at": _dt_local(item.paid_at),
"payment_method": item.payment_method,
"paid_in_shift_id": item.paid_in_shift_id,
"notes": item.notes,
"printed": item.printed,
"selected_options": item.selected_options,
"removed_ingredients": item.removed_ingredients,
})
audit_out = []
for log in o.audit_logs:
audit_out.append({
"id": log.id,
"order_id": log.order_id,
"event_type": log.event_type,
"waiter_id": log.waiter_id,
"waiter_name": _wname(log.waiter_id),
"item_ids": log.item_ids,
"amount": log.amount,
"payment_method": log.payment_method,
"note": log.note,
"created_at": _dt_local(log.created_at),
"offline_at": log.offline_at,
"is_duplicate": log.is_duplicate,
})
result.append({
"id": o.id,
"table_id": o.table_id,
"table_name": tables_map.get(o.table_id) if o.table_id else None,
"opened_by": o.opened_by,
"opened_by_name": _wname(o.opened_by),
"opened_at": _dt_local(o.opened_at),
"closed_by": o.closed_by,
"closed_by_name": _wname(o.closed_by),
"closed_at": _dt_local(o.closed_at),
"status": o.status,
"notes": o.notes,
"business_day_id": o.business_day_id,
"items": items_out,
"waiters": [{"waiter_id": w.waiter_id} for w in o.waiters],
"audit_logs": audit_out,
})
return result
@router.get("/tables/summary") @router.get("/tables/summary")
@@ -689,9 +798,8 @@ def business_days_list(
orders = db.query(Order).filter(Order.business_day_id == d.id).all() orders = db.query(Order).filter(Order.business_day_id == d.id).all()
closed_orders = [o for o in orders if o.status in ("closed", "paid")] closed_orders = [o for o in orders if o.status in ("closed", "paid")]
cancelled_orders = [o for o in orders if o.status == "cancelled"] cancelled_orders = [o for o in orders if o.status == "cancelled"]
waiter_ids = set() day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
for s in (db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()): waiter_ids = {s.waiter_id for s in day_shifts}
waiter_ids.add(s.waiter_id)
revenue = sum( revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")) sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders for o in closed_orders
@@ -710,6 +818,7 @@ def business_days_list(
"closed_order_count": len(closed_orders), "closed_order_count": len(closed_orders),
"cancellation_count": len(cancelled_orders), "cancellation_count": len(cancelled_orders),
"waiter_count": len(waiter_ids), "waiter_count": len(waiter_ids),
"shift_count": len(day_shifts),
"revenue": round(revenue, 2), "revenue": round(revenue, 2),
}) })
return {"business_days": result} return {"business_days": result}

View File

@@ -289,22 +289,66 @@ def get_shift(
return _enrich_shift(shift, db) return _enrich_shift(shift, db)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a shift, its breaks, and all orders opened during that shift."""
from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter
import json as _json
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.ended_at is None:
raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first")
# Find all orders opened during this shift's time window by this waiter
orders = db.query(Order).filter(
Order.opened_by == shift.waiter_id,
Order.opened_at >= shift.started_at,
Order.opened_at <= shift.ended_at,
).all()
for order in orders:
db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False)
db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False)
db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False)
db.delete(order)
db.delete(shift)
db.commit()
@router.get("/{shift_id}/summary") @router.get("/{shift_id}/summary")
def get_shift_summary( def get_shift_summary(
shift_id: int, shift_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_manager), user: User = Depends(require_manager),
): ):
"""Full shift summary: enriched shift data + paid items grouped by order.""" """Full shift summary with complete per-item waiter attribution.
Returns all items that either:
- were paid in this shift (paid_in_shift_id == shift_id), OR
- were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift.
Each item includes added_by_name, paid_by_name, timestamps, and order context.
Orders include opener_name and closer_name.
"""
from models.order import Order from models.order import Order
from models.table import Table
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift: if not shift:
raise HTTPException(status_code=404, detail="Shift not found") raise HTTPException(status_code=404, detail="Shift not found")
from models.table import Table waiter_id = shift.waiter_id
items = db.query(OrderItem).options(
# Collect all relevant items: paid in this shift OR added by this waiter
paid_items = db.query(OrderItem).options(
joinedload(OrderItem.product), joinedload(OrderItem.product),
joinedload(OrderItem.order), joinedload(OrderItem.order),
).filter( ).filter(
@@ -312,48 +356,115 @@ def get_shift_summary(
OrderItem.status == "paid", OrderItem.status == "paid",
).all() ).all()
# Build table_id -> display name map for all referenced tables ordered_items = db.query(OrderItem).options(
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id} joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.added_by == waiter_id,
OrderItem.added_at >= shift.started_at,
OrderItem.added_at <= shift.ended_at,
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
).all()
# Deduplicate: union by item id, paid_items takes precedence
items_by_id: dict[int, OrderItem] = {}
for item in ordered_items:
items_by_id[item.id] = item
for item in paid_items:
items_by_id[item.id] = item
all_items = list(items_by_id.values())
# Build lookup maps
all_waiter_ids = set()
for item in all_items:
all_waiter_ids.add(item.added_by)
if item.paid_by:
all_waiter_ids.add(item.paid_by)
all_order_ids = {item.order_id for item in all_items}
# Load orders with opener/closer
orders_db: dict[int, Order] = {}
if all_order_ids:
for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all():
orders_db[o.id] = o
if o.opened_by:
all_waiter_ids.add(o.opened_by)
if o.closed_by:
all_waiter_ids.add(o.closed_by)
# Load table names
table_ids = {o.table_id for o in orders_db.values() if o.table_id}
tables_map: dict[int, str] = {} tables_map: dict[int, str] = {}
if table_ids: if table_ids:
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all() for t in db.query(Table).filter(Table.id.in_(table_ids)).all():
for t in tbl_rows:
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else "" prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}" tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
orders_seen = {} # Load waiter names
for item in items: waiters_map: dict[int, str] = {}
if all_waiter_ids:
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
waiters_map[w.id] = w.full_name or w.username
def _wname(wid):
if wid is None:
return None
return waiters_map.get(wid, f"#{wid}")
# Build orders dict
orders_out: dict[int, dict] = {}
for oid, o in orders_db.items():
tid = o.table_id
orders_out[oid] = {
"order_id": oid,
"table_id": tid,
"table_name": tables_map.get(tid) if tid else None,
"opened_at": _dt(o.opened_at),
"closed_at": _dt(o.closed_at),
"opener_name": _wname(o.opened_by),
"closer_name": _wname(o.closed_by),
"status": o.status,
"items": [],
}
# Attach items to their orders
for item in all_items:
oid = item.order_id oid = item.order_id
if oid not in orders_seen: if oid not in orders_out:
o = item.order continue
tid = o.table_id if o else None orders_out[oid]["items"].append({
orders_seen[oid] = {
"order_id": oid,
"table_id": tid,
"table_name": tables_map.get(tid) if tid else None,
"opened_at": _dt(o.opened_at) if o else None,
"items": [],
}
orders_seen[oid]["items"].append({
"id": item.id, "id": item.id,
"product_name": item.product.name if item.product else f"#{item.product_id}", "product_name": item.product.name if item.product else f"#{item.product_id}",
"quantity": item.quantity, "quantity": item.quantity,
"unit_price": float(item.unit_price), "unit_price": float(item.unit_price),
"subtotal": round(float(item.unit_price) * item.quantity, 2), "subtotal": round(float(item.unit_price) * item.quantity, 2),
"status": item.status,
"added_by_id": item.added_by,
"added_by_name": _wname(item.added_by),
"added_at": _dt(item.added_at),
"paid_by_id": item.paid_by,
"paid_by_name": _wname(item.paid_by),
"paid_at": _dt(item.paid_at), "paid_at": _dt(item.paid_at),
"paid_in_shift_id": item.paid_in_shift_id,
"payment_method": item.payment_method,
}) })
# Compute hours worked # Remove orders with no items (shouldn't happen but safety net)
populated_orders = [o for o in orders_out.values() if o["items"]]
# Compute duration
started = shift.started_at started = shift.started_at
ended = shift.ended_at ended = shift.ended_at
duration_minutes = None duration_minutes = None
if started and ended: if started and ended:
duration_minutes = int((ended - started).total_seconds() / 60) duration_minutes = int((ended - started).total_seconds() / 60)
elif started: elif started:
from datetime import datetime, timezone as tz from datetime import timezone as tz
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60) now = datetime.now(tz.utc)
s = started if started.tzinfo else started.replace(tzinfo=tz.utc)
duration_minutes = int((now - s).total_seconds() / 60)
enriched = _enrich_shift(shift, db) enriched = _enrich_shift(shift, db)
enriched["orders"] = list(orders_seen.values()) enriched["orders"] = populated_orders
enriched["duration_minutes"] = duration_minutes enriched["duration_minutes"] = duration_minutes
return enriched return enriched

View File

@@ -76,7 +76,12 @@ def _compute_expiry_fields(expires_at_str: str | None) -> dict:
def _get_local_ip() -> str | None: def _get_local_ip() -> str | None:
"""Best-effort detection of the machine's LAN IP address.""" """Best-effort detection of the host machine's LAN IP address.
When running inside Docker the socket trick returns the container/bridge IP,
so we honour HOST_IP if it is explicitly provided via the environment."""
import os
if host_ip := os.environ.get("HOST_IP", "").strip():
return host_ip
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80)) s.connect(("8.8.8.8", 80))

View File

@@ -4,7 +4,7 @@ import useAuthStore from './store/authStore'
import AppLayout from './layouts/AppLayout' import AppLayout from './layouts/AppLayout'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import SetupWizard from './pages/SetupWizard' import SetupWizard from './pages/SetupWizard'
import OperationsPage from './pages/OperationsPage' import DashboardPage from './pages/DashboardPage'
import TablesPage from './pages/TablesPage' import TablesPage from './pages/TablesPage'
import OrderDetailPage from './pages/OrderDetailPage' import OrderDetailPage from './pages/OrderDetailPage'
import ManagementPage from './pages/ManagementPage' import ManagementPage from './pages/ManagementPage'
@@ -74,9 +74,9 @@ export default function App() {
<Route path="/setup" element={<SetupWizard />} /> <Route path="/setup" element={<SetupWizard />} />
<Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} /> <Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} />
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}> <Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route index element={<Navigate to="/operations" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Navigate to="/operations" replace />} /> <Route path="operations" element={<Navigate to="/dashboard" replace />} />
<Route path="operations" element={<OperationsPage />} /> <Route path="dashboard" element={<DashboardPage />} />
<Route path="tables" element={<TablesPage />} /> <Route path="tables" element={<TablesPage />} />
<Route path="orders/:orderId" element={<OrderDetailPage />} /> <Route path="orders/:orderId" element={<OrderDetailPage />} />
<Route path="management" element={<ManagementPage />} /> <Route path="management" element={<ManagementPage />} />

View File

@@ -3,7 +3,7 @@ import { useState } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react' import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
const NAV = [ const NAV = [
{ to: '/operations', icon: BarChart2, label: 'Διοίκηση' }, { to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' }, { to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' }, { to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
{ to: '/management', icon: Package, label: 'Διαχείριση' }, { to: '/management', icon: Package, label: 'Διαχείριση' },
@@ -17,7 +17,7 @@ export default function Sidebar() {
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}> <aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
{/* Logo / collapse toggle */} {/* Logo / collapse toggle */}
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700"> <div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
{!collapsed && <span className="font-bold text-lg tracking-wide">POS</span>} {!collapsed && <span className="font-bold text-lg tracking-wide">XeniaPOS</span>}
<button <button
onClick={() => setCollapsed(c => !c)} onClick={() => setCollapsed(c => !c)}
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto" className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"

View File

@@ -6,6 +6,7 @@ import client from '../api/client'
import Badge from '../ui/Badge' import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal' import { ConfirmModal } from '../ui/Modal'
import { LicenseContext } from '../layouts/AppLayout' import { LicenseContext } from '../layouts/AppLayout'
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
// Helpers // Helpers
@@ -479,7 +480,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
// KPI Card // KPI Card
function KpiCard({ label, value, sub, accent = '#3758c9', pct }) { function KpiCard({ label, value, sub, accent = '#3758c9', pct, private: isPrivate }) {
return ( return (
<div style={{ <div style={{
background: 'white', background: 'white',
@@ -491,8 +492,19 @@ function KpiCard({ label, value, sub, accent = '#3758c9', pct }) {
flex: 1, flex: 1,
}}> }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.7 }}>{label}</div> <div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.7 }}>{label}</div>
<div style={{ fontSize: 30, fontWeight: 700, color: '#111315', letterSpacing: -0.5, lineHeight: 1.1, fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>{value}</div> <div style={{
{sub && <div style={{ fontSize: 12, color: '#5a6169' }}>{sub}</div>} fontSize: 30, fontWeight: 700, color: '#111315', letterSpacing: -0.5, lineHeight: 1.1,
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
filter: isPrivate ? 'blur(8px)' : 'none',
userSelect: isPrivate ? 'none' : 'auto',
transition: 'filter 200ms ease',
}}>{value}</div>
{sub && <div style={{
fontSize: 12, color: '#5a6169',
filter: isPrivate ? 'blur(6px)' : 'none',
userSelect: isPrivate ? 'none' : 'auto',
transition: 'filter 200ms ease',
}}>{sub}</div>}
{pct != null && ( {pct != null && (
<div style={{ marginTop: 4, height: 6, borderRadius: 3, background: '#edeff1', overflow: 'hidden' }}> <div style={{ marginTop: 4, height: 6, borderRadius: 3, background: '#edeff1', overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, pct)}%`, height: '100%', background: accent, borderRadius: 3, transition: 'width 300ms ease' }} /> <div style={{ width: `${Math.min(100, pct)}%`, height: '100%', background: accent, borderRadius: 3, transition: 'width 300ms ease' }} />
@@ -535,6 +547,7 @@ function TableChip({ name, status, amount, onClick }) {
// End shift confirmation modal // End shift confirmation modal
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) { function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
const [notes, setNotes] = useState('')
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"
onClick={e => { if (e.target === e.currentTarget) onClose() }}> onClick={e => { if (e.target === e.currentTarget) onClose() }}>
@@ -559,10 +572,24 @@ function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span> <span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
</div> </div>
</div> </div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
rows={2}
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
onFocus={e => e.target.style.borderColor = '#6366f1'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p> <p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button> <button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
<button onClick={onConfirm} disabled={busy} <button onClick={() => onConfirm(notes)} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60"> className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'} {busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
</button> </button>
@@ -572,123 +599,6 @@ function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
) )
} }
// Shift summary modal
function ShiftSummaryModal({ shiftId, onConfirm }) {
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
client.get(`/api/shifts/${shiftId}/summary`)
.then(r => setSummary(r.data))
.catch(() => setSummary(null))
.finally(() => setLoading(false))
}, [shiftId])
function fmtMins(mins) {
if (mins == null) return '—'
const h = Math.floor(mins / 60)
const m = mins % 60
return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
}
const totalItems = summary?.orders?.reduce((s, o) => s + o.items.reduce((ss, i) => ss + i.quantity, 0), 0) ?? 0
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}}>
<div style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 860,
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 24px 64px rgba(0,0,0,0.25)',
}}>
{/* Header */}
<div style={{ padding: '20px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#111315' }}>Σύνοψη Βάρδιας</div>
{summary && <div style={{ fontSize: 13, color: '#5a6169', marginTop: 3 }}>{summary.waiter_name}</div>}
</div>
{loading && <div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Φόρτωση</div>}
{!loading && summary && (
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 20 }}>
{[
{ label: 'Έναρξη', value: fmtTime(summary.started_at) },
{ label: 'Ώρες', value: fmtMins(summary.duration_minutes) },
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEuro(summary.starting_cash) : '—' },
{ label: 'Είσπραξη', value: fmtEuro(summary.total_collected), accent: '#2f9e5e' },
].map(k => (
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 12, padding: '16px 12px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.6, lineHeight: 1.3 }}>{k.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: k.accent || '#111315', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>{k.value}</div>
</div>
))}
</div>
{/* Net to deliver */}
<div style={{ background: '#eff6ff', borderRadius: 12, padding: '12px 16px', marginBottom: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#3758c9', textTransform: 'uppercase', letterSpacing: 0.5 }}>Σύνολο προς παράδοση</div>
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 2 }}>Είσπραξη + αρχικά μετρητά</div>
</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#3758c9', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>
{fmtEuro(summary.net_to_deliver)}
</div>
</div>
{/* Orders breakdown */}
<div style={{ fontSize: 12, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 10 }}>
Παραγγελίες ({summary.orders.length}) · {totalItems} αντικείμενα
</div>
{summary.orders.length === 0 && (
<p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '16px 0' }}>Δεν υπάρχουν πληρωμές σε αυτή τη βάρδια</p>
)}
{summary.orders.map(o => (
<div key={o.order_id} style={{ borderRadius: 10, border: '1px solid #edeff1', marginBottom: 8, overflow: 'hidden' }}>
<div style={{ padding: '8px 14px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
Παραγγελία #{o.order_id}
{(o.table_name || o.table_id) && <span style={{ color: '#8a9099', fontWeight: 400, marginLeft: 6 }}>· Τραπέζι {o.table_name ?? o.table_id}</span>}
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#2f9e5e' }}>
{fmtEuro(o.items.reduce((s, i) => s + i.subtotal, 0))}
</span>
</div>
{o.items.map(item => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 14px', fontSize: 13, borderTop: '1px solid #f4f4f2' }}>
<span style={{ color: '#374151' }}>{item.product_name} <span style={{ color: '#8a9099' }}>×{item.quantity}</span></span>
<span style={{ color: '#111315', fontWeight: 500 }}>{fmtEuro(item.subtotal)}</span>
</div>
))}
</div>
))}
</div>
)}
{!loading && !summary && (
<div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Σφάλμα φόρτωσης</div>
)}
{/* Footer — no close on outside click, must confirm */}
<div style={{ padding: '16px 24px', borderTop: '1px solid #edeff1', flexShrink: 0 }}>
<button
onClick={onConfirm}
style={{
width: '100%', height: 44, borderRadius: 12,
background: '#3758c9', color: 'white', border: 'none',
fontSize: 15, fontWeight: 700, cursor: 'pointer',
}}
> Επιβεβαίωση και Κλείσιμο</button>
</div>
</div>
</div>
)
}
// Compose for single waiter (quick message from shift row) // Compose for single waiter (quick message from shift row)
function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) { function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
@@ -742,7 +652,7 @@ function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
) )
} }
function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter }) { function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter, privacyMode }) {
return ( return (
<div style={{ <div style={{
background: 'white', border: '1px solid #edeff1', background: 'white', border: '1px solid #edeff1',
@@ -797,7 +707,12 @@ function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, o
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 1 }}> <div style={{ fontSize: 11, color: '#5a6169', marginTop: 1 }}>
από {fmtTime(s.started_at)} · {fmtDuration(s.started_at)} από {fmtTime(s.started_at)} · {fmtDuration(s.started_at)}
{s.total_collected > 0 && ( {s.total_collected > 0 && (
<span style={{ color: '#2f9e5e', fontWeight: 600, marginLeft: 6 }}>{fmtEuro(s.total_collected)}</span> <span style={{
color: '#2f9e5e', fontWeight: 600, marginLeft: 6,
filter: privacyMode ? 'blur(6px)' : 'none',
userSelect: privacyMode ? 'none' : 'auto',
transition: 'filter 200ms ease',
}}>{fmtEuro(s.total_collected)}</span>
)} )}
</div> </div>
</div> </div>
@@ -1216,7 +1131,7 @@ function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onV
// Main page // Main page
export default function OperationsPage() { export default function DashboardPage() {
const [showStartShift, setShowStartShift] = useState(false) const [showStartShift, setShowStartShift] = useState(false)
const [closeDetails, setCloseDetails] = useState(null) const [closeDetails, setCloseDetails] = useState(null)
const [forceClosing, setForceClosing] = useState(false) const [forceClosing, setForceClosing] = useState(false)
@@ -1229,6 +1144,7 @@ export default function OperationsPage() {
const [shiftSummaryId, setShiftSummaryId] = useState(null) // show summary for this shift id const [shiftSummaryId, setShiftSummaryId] = useState(null) // show summary for this shift id
// Quick message to single waiter // Quick message to single waiter
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name } const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const license = useContext(LicenseContext) const license = useContext(LicenseContext)
@@ -1315,15 +1231,15 @@ export default function OperationsPage() {
} }
} }
async function handleEndShiftConfirm() { async function handleEndShiftConfirm(notes) {
if (!endShiftTarget) return if (!endShiftTarget) return
setEndShiftBusy(true) setEndShiftBusy(true)
try { try {
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, {}) await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
qc.invalidateQueries({ queryKey: ['active-shifts'] }) qc.invalidateQueries({ queryKey: ['active-shifts'] })
const summaryId = endShiftTarget.id const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
setEndShiftTarget(null) setEndShiftTarget(null)
setShiftSummaryId(summaryId) setShiftSummaryId(summaryTarget)
} catch (e) { } catch (e) {
toast.error(e.response?.data?.detail || 'Σφάλμα') toast.error(e.response?.data?.detail || 'Σφάλμα')
} finally { } finally {
@@ -1432,7 +1348,32 @@ export default function OperationsPage() {
{isOpen && businessDay?.opened_at && ` · από ${fmtTime(businessDay.opened_at)} · ${fmtDuration(businessDay.opened_at)}`} {isOpen && businessDay?.opened_at && ` · από ${fmtTime(businessDay.opened_at)} · ${fmtDuration(businessDay.opened_at)}`}
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
onClick={() => setPrivacyMode(p => { const next = !p; localStorage.setItem('privacyMode', next); return next })}
title={privacyMode ? 'Εμφάνιση ποσών' : 'Απόκρυψη ποσών'}
style={{
height: 38, width: 38, borderRadius: 10, flexShrink: 0,
border: '1px solid #dfe2e6',
background: privacyMode ? '#1e293b' : 'white',
color: privacyMode ? '#94a3b8' : '#5a6169',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, transition: 'background 200ms, color 200ms',
}}
>
{privacyMode ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
{isOpen ? ( {isOpen ? (
<button onClick={() => handleCloseDay(false)} style={{ <button onClick={() => handleCloseDay(false)} style={{
height: 38, padding: '0 18px', borderRadius: 10, height: 38, padding: '0 18px', borderRadius: 10,
@@ -1456,6 +1397,7 @@ export default function OperationsPage() {
value={fmtEuro(totalRevenue)} value={fmtEuro(totalRevenue)}
sub={`${dayOrders.length} παραγγελί${dayOrders.length !== 1 ? 'ες' : 'α'} · μ.ο. ${fmtEuro(avgTicket)}`} sub={`${dayOrders.length} παραγγελί${dayOrders.length !== 1 ? 'ες' : 'α'} · μ.ο. ${fmtEuro(avgTicket)}`}
accent="#3758c9" accent="#3758c9"
private={privacyMode}
/> />
<KpiCard <KpiCard
label="Τραπέζια σε χρήση" label="Τραπέζια σε χρήση"
@@ -1475,6 +1417,7 @@ export default function OperationsPage() {
value={fmtEuro(totalCollected)} value={fmtEuro(totalCollected)}
sub={`Σύνολο από ${activeShifts.length} βάρδι${activeShifts.length === 1 ? 'α' : 'ες'}`} sub={`Σύνολο από ${activeShifts.length} βάρδι${activeShifts.length === 1 ? 'α' : 'ες'}`}
accent="#0d7a8a" accent="#0d7a8a"
private={privacyMode}
/> />
{pendingPrintOrders.length > 0 && ( {pendingPrintOrders.length > 0 && (
<KpiCard <KpiCard
@@ -1500,6 +1443,7 @@ export default function OperationsPage() {
onStartShift={() => setShowStartShift(true)} onStartShift={() => setShowStartShift(true)}
onEndShift={(shift) => setEndShiftTarget(shift)} onEndShift={(shift) => setEndShiftTarget(shift)}
onMessageWaiter={(s) => setMessageWaiter({ id: s.waiter_id, name: s.waiter_name })} onMessageWaiter={(s) => setMessageWaiter({ id: s.waiter_id, name: s.waiter_name })}
privacyMode={privacyMode}
/> />
{/* Tables overview — SECOND */} {/* Tables overview — SECOND */}
@@ -1595,9 +1539,10 @@ export default function OperationsPage() {
)} )}
{shiftSummaryId && ( {shiftSummaryId && (
<ShiftSummaryModal <ShiftDetailModal
shiftId={shiftSummaryId} shiftId={shiftSummaryId.id}
onConfirm={() => setShiftSummaryId(null)} shiftWaiterId={shiftSummaryId.waiter_id}
onClose={() => setShiftSummaryId(null)}
/> />
)} )}

View File

@@ -1,788 +0,0 @@
import { useState, useContext } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
import Button from '../ui/Button'
import { LicenseContext } from '../layouts/AppLayout'
// ─── Business Day + Shift Management Panel ───────────────────────────────────
function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function fmtShiftDuration(iso) {
if (!iso) return ''
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
if (mins < 60) return `${mins}λ`
const h = Math.floor(mins / 60); const m = mins % 60
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
}
function StartShiftModal({ waiters, onClose, onStart }) {
const [waiterId, setWaiterId] = useState('')
const [cash, setCash] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return }
setBusy(true)
try {
await onStart(Number(waiterId), cash ? parseFloat(cash) : null)
onClose()
} catch (e) {
toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Έναρξη Βάρδιας</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
<option value=""> Επιλέξτε </option>
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
</select>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά ()</label>
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => setCash(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
</div>
<div className="flex gap-3 pt-1">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
<button onClick={submit} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
</button>
</div>
</div>
</div>
)
}
function CloseConfirmModal({ details, onClose, onConfirm, busy }) {
const hasPendingPayments = details.partially_paid > 0
if (!hasPendingPayments) {
// All tables open but nothing owed — safe to close, just needs confirmation
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
<p className="font-semibold">
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
</p>
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
Ακύρωση
</button>
<button onClick={onConfirm} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
</button>
</div>
</div>
</div>
)
}
// Some tables have unpaid items — revenue will be lost, needs hard warning
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="text-red-600 text-lg font-bold">!</span>
</div>
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
<p className="font-semibold">
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
</p>
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
</div>
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
Ακύρωση
</button>
<button onClick={onConfirm} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
</button>
</div>
</div>
</div>
)
}
function BusinessDayPanel() {
const qc = useQueryClient()
const [showStartShift, setShowStartShift] = useState(false)
const [closeDetails, setCloseDetails] = useState(null)
const [forceClosing, setForceClosing] = useState(false)
const [licenseBlock, setLicenseBlock] = useState(null)
const license = useContext(LicenseContext)
const { data: businessDay } = useQuery({
queryKey: ['business-day'],
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
refetchInterval: 15_000,
})
const { data: activeShifts = [] } = useQuery({
queryKey: ['active-shifts'],
queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
refetchInterval: 15_000,
})
const { data: allWaiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const waitersWithoutShift = allWaiters.filter(
w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
)
const openDayMut = useMutation({
mutationFn: () => client.post('/api/business-day/open', {}),
onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
onError: (e) => {
const detail = e.response?.data?.detail
if (detail?.code === 'SYSTEM_LOCKED' || detail?.code === 'LICENSE_EXPIRED') {
setLicenseBlock({ code: detail.code, message: detail.message })
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα')
}
},
})
function handleOpenDay() {
if (license?.isBlocked) {
setLicenseBlock({
code: license.lock_reason === 'admin' ? 'SYSTEM_LOCKED' : 'LICENSE_EXPIRED',
message: license.lock_reason === 'admin'
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
: 'Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.',
})
return
}
openDayMut.mutate()
}
async function handleCloseDay(force = false) {
setForceClosing(force)
try {
await client.post('/api/business-day/close', { force })
toast.success('Ημέρα έκλεισε!')
setCloseDetails(null)
qc.invalidateQueries({ queryKey: ['business-day'] })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
} catch (e) {
const detail = e.response?.data?.detail
if (e.response?.status === 409 && detail?.open_orders) {
setCloseDetails(detail)
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
}
} finally {
setForceClosing(false)
}
}
async function handleEndShift(shiftId, waiterName) {
if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return
try {
await client.post(`/api/shifts/manager/end/${shiftId}`, {})
toast.success('Βάρδια έκλεισε')
qc.invalidateQueries({ queryKey: ['active-shifts'] })
} catch (e) {
toast.error(e.response?.data?.detail || 'Σφάλμα')
}
}
async function handleStartShift(waiterId, startingCash) {
await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash })
toast.success('Βάρδια ξεκίνησε!')
qc.invalidateQueries({ queryKey: ['active-shifts'] })
}
const isOpen = !!businessDay
return (
<>
<div className="rounded-2xl border overflow-hidden"
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
{/* Header row */}
<div className="flex items-center justify-between px-5 py-3"
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
<div className="flex items-center gap-3">
<div style={{
width: 10, height: 10, borderRadius: '50%',
background: isOpen ? '#16a34a' : '#9ca3af',
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
}} />
<div>
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
</span>
{isOpen && businessDay?.opened_at && (
<span className="text-xs text-gray-500 ml-2">
από {fmtTime(businessDay.opened_at)}
</span>
)}
</div>
</div>
<div className="flex gap-2">
{isOpen && waitersWithoutShift.length > 0 && (
<button
onClick={() => setShowStartShift(true)}
className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
>
+ Βάρδια
</button>
)}
{isOpen ? (
<button
onClick={() => handleCloseDay(false)}
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
>
Κλείσιμο Ημέρας
</button>
) : (
<button
onClick={handleOpenDay}
disabled={openDayMut.isPending}
className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
>
{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
</button>
)}
</div>
</div>
{/* Active shifts */}
{isOpen && (
<div className="px-5 py-3 border-t border-gray-100 bg-white">
{activeShifts.length === 0 ? (
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
) : (
<div className="flex flex-wrap gap-2">
{activeShifts.map(s => (
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
<div>
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
{s.total_collected > 0 && (
<span className="text-xs text-green-700 ml-2 font-medium">{s.total_collected.toFixed(2)}</span>
)}
</div>
<button
onClick={() => handleEndShift(s.id, s.waiter_name)}
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
title="Τέλος βάρδιας"
>
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{showStartShift && (
<StartShiftModal
waiters={waitersWithoutShift}
onClose={() => setShowStartShift(false)}
onStart={handleStartShift}
/>
)}
{closeDetails && (
<CloseConfirmModal
details={closeDetails}
onClose={() => setCloseDetails(null)}
onConfirm={() => handleCloseDay(true)}
busy={forceClosing}
/>
)}
{licenseBlock && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm text-center space-y-4">
<div className="flex justify-center">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
licenseBlock.code === 'SYSTEM_LOCKED' ? 'bg-red-100' : 'bg-orange-100'
}`}>
<span className="text-2xl">{licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'}</span>
</div>
</div>
<h2 className="text-[15px] font-bold text-slate-900">
{licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'}
</h2>
<p className="text-[13px] text-slate-600">{licenseBlock.message}</p>
<button
onClick={() => setLicenseBlock(null)}
className="w-full h-10 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[13px] font-semibold transition-colors"
>
Κλείσιμο
</button>
</div>
</div>
)}
</>
)
}
const FILTERS = ['all', 'open', 'partially_paid', 'free']
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
// ─── Design tokens ────────────────────────────────────────────────────────────
const COLORS = {
open: {
label: 'Ανοιχτό',
tint: '#eef7f0', tintStrong: '#d7ecdc',
accent: '#2f9e5e', ink: '#1f7042',
},
partially_paid: {
label: 'Μερική πληρ.',
tint: '#f4eefb', tintStrong: '#e3d4f3',
accent: '#7a44c9', ink: '#57309a',
},
free: {
label: 'Ελεύθερο',
tint: '#f4f4f2', tintStrong: '#dfe2e6',
accent: '#8a9099', ink: '#5a6169',
},
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatEuro(n) {
return '€' + parseFloat(n).toFixed(2)
}
function formatDuration(openedAt) {
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
if (mins < 60) return `${mins}m`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}h` : `${h}h ${m}m`
}
function occupiedMinsFromDate(openedAt) {
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
}
function orderTotal(items = []) {
return items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
}
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return palette[h % palette.length]
}
function WaiterBubble({ waiter, size = 26 }) {
// waiter: { name, avatarUrl }
if (waiter.avatarUrl) {
return (
<img
src={waiter.avatarUrl}
alt={waiter.name}
style={{
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
}}
/>
)
}
const parts = waiter.name.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(waiter.name),
color: 'white',
fontSize: size * 0.42,
fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
)
}
// ─── V1 Table Card ────────────────────────────────────────────────────────────
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, onClick }) {
const s = COLORS[status] || COLORS.free
const [hover, setHover] = useState(false)
const [pressed, setPressed] = useState(false)
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
const showMulti = waiters.length >= 3
return (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false) }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: '100%', minWidth: 330, height: 200,
padding: '16px 18px 16px 24px',
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 14,
boxShadow: pressed
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
: hover
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
flexShrink: 0,
}}
>
{/* left accent bar */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent,
borderRadius: '14px 0 0 14px',
}} />
{/* Header: name + status pill */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 34, fontWeight: 700, lineHeight: 1,
letterSpacing: -0.5,
color: '#111315',
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px',
borderRadius: 999,
background: s.accent,
color: 'white',
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
{/* Flags row */}
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
{hasPendingPrint && (
<span style={{
fontSize: 11, fontWeight: 700,
background: '#92400e', color: '#fcd34d',
borderRadius: 999, padding: '2px 8px',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
Εκκρεμής εκτύπωση
</span>
)}
</div>
{/* Stats row */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
alignItems: 'end',
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<WaiterBubble waiter={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: '#2b2f33',
background: 'white', border: '1px solid #dfe2e6',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<WaiterBubble waiter={w} size={24} />
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
</div>
))
)}
</div>
</button>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [filter, setFilter] = useState('all')
const [retryingId, setRetryingId] = useState(null)
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: tables = [], isLoading: tablesLoading } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
refetchInterval: 5_000,
})
const { data: orders = [], isLoading: ordersLoading } = useQuery({
queryKey: ['orders-active'],
queryFn: () => client.get('/api/orders/').then(r => r.data),
refetchInterval: 5_000,
})
const { data: waiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
// waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
const waiterMap = Object.fromEntries(waiters.map(w => {
const name = w.full_name || w.nickname || w.username
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
const avatarUrl = w.avatar_url ?? null
return [w.id, { name, shortName, avatarUrl }]
}))
const tableCards = tables.map(table => {
const order = orders.find(o =>
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
)
const tableStatus = order ? order.status : 'free'
const hasPendingPrint = order
? order.items.some(i => i.status === 'active' && !i.printed)
: false
return { table, order, tableStatus, hasPendingPrint }
})
const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
async function retrySingleOrder(orderId) {
setRetryingId(orderId)
try {
const res = await client.post(`/api/orders/${orderId}/retry-print`)
const results = res.data.print_results ?? []
const allOk = results.length === 0 || results.every(r => r.success)
if (allOk) {
toast.success('Εκτυπώθηκε επιτυχώς')
} else {
const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
toast.error(`Αποτυχία: ${failed}`)
}
queryClient.invalidateQueries({ queryKey: ['orders-active'] })
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setRetryingId(null)
}
}
async function retryAllOrders() {
for (const { order } of pendingPrintOrders) {
if (order) await retrySingleOrder(order.id)
}
}
const filtered = filter === 'all'
? tableCards
: tableCards.filter(c => c.tableStatus === filter)
if (tablesLoading || ordersLoading) {
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
}
return (
<div className="overflow-y-auto h-full p-6 space-y-6">
<BusinessDayPanel />
<div className="flex items-center justify-end">
<div className="flex gap-2">
{FILTERS.map(f => (
<Button
key={f}
size="sm"
variant={filter === f ? 'primary' : 'secondary'}
onClick={() => setFilter(f)}
>
{FILTER_LABELS[f]}
</Button>
))}
</div>
</div>
{filtered.length === 0 && (
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
{filtered.map(({ table, order, tableStatus, hasPendingPrint }) => {
const waiterNames = order
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
: []
const amount = order ? orderTotal(order.items) : null
return (
<TableCardV1
key={table.id}
name={table.label || `T${table.number}`}
status={tableStatus}
amount={amount}
openedAt={order?.opened_at ?? null}
waiters={waiterNames}
hasPendingPrint={hasPendingPrint}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
/>
)
})}
</div>
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
{pendingPrintOrders.length > 0 && (
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
<div className="flex items-center gap-3">
<span style={{ fontSize: 20 }}></span>
<div>
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
<p className="text-xs text-orange-700 mt-0.5">
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
</p>
</div>
</div>
<Button
size="sm"
variant="primary"
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
onClick={retryAllOrders}
disabled={retryingId !== null}
>
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
</Button>
</div>
<div className="divide-y divide-orange-50">
{pendingPrintOrders.map(({ table, order }) => {
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
const tableName = table.label || `T${table.number}`
return (
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
{tableName}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-800">
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
</p>
<p className="text-xs text-gray-500 truncate">
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/orders/${order.id}`)}
>
Λεπτομέρειες
</Button>
<Button
size="sm"
variant="primary"
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
onClick={() => retrySingleOrder(order.id)}
disabled={retryingId === order.id}
>
{retryingId === order.id ? '…' : 'Εκτύπωση'}
</Button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
import client from '../api/client' import client from '../api/client'
import Badge from '../ui/Badge' import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal' import { ConfirmModal } from '../ui/Modal'
import PaymentMethodModal from '../ui/PaymentMethodModal'
function PrintOrderModal({ onClose, onPrint, printers }) { function PrintOrderModal({ onClose, onPrint, printers }) {
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '') const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
@@ -40,6 +41,24 @@ function itemTotal(item) {
return (item.unit_price * item.quantity).toFixed(2) return (item.unit_price * item.quantity).toFixed(2)
} }
const PAYMENT_METHOD_STYLES = {
cash: { label: 'ΜΕΤΡΗΤΑ', color: '#15803d', bg: '#f0fdf4', border: '#86efac' },
card: { label: 'ΚΑΡΤΑ', color: '#1d4ed8', bg: '#eff6ff', border: '#93c5fd' },
transfer: { label: 'ΤΡΑΠΕΖΙΚΗ', color: '#6d28d9', bg: '#f5f3ff', border: '#c4b5fd' },
treat: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
comp: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
}
function PayMethodChip({ method }) {
if (!method) return null
const s = PAYMENT_METHOD_STYLES[method] || { label: method.toUpperCase(), color: '#374151', bg: '#f3f4f6', border: '#d1d5db' }
return (
<span style={{ fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 4, background: s.bg, color: s.color, border: `1px solid ${s.border}`, letterSpacing: 0.3 }}>
{s.label}
</span>
)
}
function formatDate(dt) { function formatDate(dt) {
if (!dt) return '—' if (!dt) return '—'
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' }) return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
@@ -59,6 +78,9 @@ function AuditTab({ order, waiterMap }) {
if (!order.audit_logs || order.audit_logs.length === 0) { if (!order.audit_logs || order.audit_logs.length === 0) {
return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p> return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
} }
const itemsById = Object.fromEntries((order.items || []).map(i => [i.id, i]))
return ( return (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{order.audit_logs.map(log => { {order.audit_logs.map(log => {
@@ -70,8 +92,20 @@ function AuditTab({ order, waiterMap }) {
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' : log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' : log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
: 'bg-blue-100 text-blue-700' : 'bg-blue-100 text-blue-700'
// Show offline_at (real payment time) when available, else server created_at
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at) const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
// Resolve item names for ITEMS_ADDED and ITEM_CANCELLED events
let itemNames = []
if (log.item_ids && (log.event_type === 'ITEMS_ADDED' || log.event_type === 'ITEM_CANCELLED')) {
try {
const ids = JSON.parse(log.item_ids)
itemNames = ids.map(id => {
const item = itemsById[id]
return item ? `${item.product?.name ?? `#${item.product_id}`} ×${item.quantity}` : null
}).filter(Boolean)
} catch { /* ignore */ }
}
return ( return (
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}> <div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
<div className="shrink-0 mt-0.5"> <div className="shrink-0 mt-0.5">
@@ -90,7 +124,14 @@ function AuditTab({ order, waiterMap }) {
</span> </span>
)} )}
{log.payment_method && ( {log.payment_method && (
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span> <span className="ml-1"><PayMethodChip method={log.payment_method} /></span>
)}
{itemNames.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{itemNames.map((name, i) => (
<span key={i} className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">{name}</span>
))}
</div>
)} )}
</div> </div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
@@ -115,6 +156,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const [tab, setTab] = useState('overview') const [tab, setTab] = useState('overview')
const [confirmAction, setConfirmAction] = useState(null) // { type, payload } const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
const [showPrintModal, setShowPrintModal] = useState(false) const [showPrintModal, setShowPrintModal] = useState(false)
const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection
const { data: order, isLoading } = useQuery({ const { data: order, isLoading } = useQuery({
queryKey: ['order', orderId], queryKey: ['order', orderId],
@@ -179,11 +221,21 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
}) })
const payItems = useMutation({ const payItems = useMutation({
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }), mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }),
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() }, onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα πληρωμής'), onError: () => toast.error('Σφάλμα πληρωμής'),
}) })
function requestPay(item_ids) {
setPayPending(item_ids)
}
function confirmPay(method) {
if (!payPending) return
payItems.mutate({ item_ids: payPending, payment_method: method })
setPayPending(null)
}
function handleConfirm() { function handleConfirm() {
if (!confirmAction) return if (!confirmAction) return
const { type, payload } = confirmAction const { type, payload } = confirmAction
@@ -279,48 +331,60 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
{order.items.length === 0 && ( {order.items.length === 0 && (
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p> <p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
)} )}
{order.items.map(item => ( {order.items.map(item => {
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}> const isCancelled = item.status === 'cancelled'
<div className="flex-1 min-w-0"> const isClosedItem = item.status === 'closed'
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p> const badgeStatus = isClosedItem ? 'closed_item' : item.status
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>} return (
<p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p> <div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${isCancelled ? 'opacity-40 line-through' : ''} ${isClosedItem ? 'bg-amber-50/50' : ''}`}>
{item.paid_by && ( <div className="flex-1 min-w-0">
<p className="text-xs text-green-600 mt-0.5"> <p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`} {item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''} <p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p>
</p> {item.paid_by && (
)} <p className="text-xs text-green-600 mt-0.5">
Πληρώθηκε: {item.paid_by_name ?? waiterMap[item.paid_by] ?? `#${item.paid_by}`}
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
{item.payment_method && (
<span style={{ color: (PAYMENT_METHOD_STYLES[item.payment_method] || {}).color ?? '#374151', fontWeight: 600 }}>
{' · '}{(PAYMENT_METHOD_STYLES[item.payment_method] || { label: item.payment_method.toUpperCase() }).label}
</span>
)}
</p>
)}
{isClosedItem && (
<p className="text-xs text-amber-700 mt-0.5 font-medium">Απλήρωτο κλείστηκε από manager</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => requestPay([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> )
<Badge status={item.status} /> })}
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div>
))}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{isOpen && !readOnly && activeItems.length > 0 && ( {isOpen && !readOnly && activeItems.length > 0 && (
<button <button
onClick={() => payItems.mutate(activeItems.map(i => i.id))} onClick={() => requestPay(activeItems.map(i => i.id))}
className="btn btn-primary" className="btn btn-primary"
> >
Πληρωμή όλων Πληρωμή όλων
@@ -357,6 +421,14 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div> </div>
)} )}
{payPending && (
<PaymentMethodModal
onSelect={confirmPay}
onCancel={() => setPayPending(null)}
title={payPending.length === 1 ? 'Τρόπος Πληρωμής;' : `Πληρωμή ${payPending.length} αντικειμένων;`}
/>
)}
{confirmAction && ( {confirmAction && (
<ConfirmModal <ConfirmModal
title={ title={

View File

@@ -1,7 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import AppInfoTab from './tabs/AppInfoTab' import AppInfoTab from './tabs/AppInfoTab'
import ColoursTab from './tabs/ColoursTab' import ColoursTab from './tabs/ColoursTab'
import DevelopmentTab from './tabs/DevelopmentTab'
import OperationTab from './tabs/OperationTab' import OperationTab from './tabs/OperationTab'
import PrintFontsTab from './tabs/PrintFontsTab' import PrintFontsTab from './tabs/PrintFontsTab'
import SecurityTab from './tabs/SecurityTab' import SecurityTab from './tabs/SecurityTab'
@@ -13,7 +12,6 @@ const TABS = [
{ id: 'operation', label: 'Λειτουργία' }, { id: 'operation', label: 'Λειτουργία' },
{ id: 'colours', label: 'Εμφάνιση' }, { id: 'colours', label: 'Εμφάνιση' },
{ id: 'print-fonts', label: 'Εκτύπωση' }, { id: 'print-fonts', label: 'Εκτύπωση' },
{ id: 'development', label: 'dev' },
] ]
export default function SettingsPage() { export default function SettingsPage() {
@@ -28,7 +26,6 @@ export default function SettingsPage() {
{activeTab === 'operation' && <OperationTab />} {activeTab === 'operation' && <OperationTab />}
{activeTab === 'colours' && <ColoursTab />} {activeTab === 'colours' && <ColoursTab />}
{activeTab === 'print-fonts' && <PrintFontsTab />} {activeTab === 'print-fonts' && <PrintFontsTab />}
{activeTab === 'development' && <DevelopmentTab />}
</div> </div>
</div> </div>
) )

View File

@@ -69,6 +69,7 @@ export default function ReportsPage() {
restaurant: 'today', restaurant: 'today',
ops: 'printer-history', ops: 'printer-history',
}) })
const [subProps, setSubProps] = useState({})
const parent = PARENT_TABS.find(t => t.id === activeParent) const parent = PARENT_TABS.find(t => t.id === activeParent)
const activeSubId = activeSubByParent[activeParent] const activeSubId = activeSubByParent[activeParent]
@@ -76,15 +77,26 @@ export default function ReportsPage() {
const { Component: SubComponent } = sub const { Component: SubComponent } = sub
function setActiveSub(id) { function setActiveSub(id) {
setSubProps({})
setActiveSubByParent(prev => ({ ...prev, [activeParent]: id })) setActiveSubByParent(prev => ({ ...prev, [activeParent]: id }))
} }
function navigateTo({ parent: parentId, sub: subId, ...props }) {
setSubProps(props)
setActiveSubByParent(prev => ({ ...prev, [parentId]: subId }))
setActiveParent(parentId)
}
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<TabGroup tabs={PARENT_TABS} active={activeParent} onChange={setActiveParent} /> <TabGroup tabs={PARENT_TABS} active={activeParent} onChange={setActiveParent} />
<TabBar tabs={parent.subTabs} active={activeSubId} onChange={setActiveSub} /> <TabBar tabs={parent.subTabs} active={activeSubId} onChange={setActiveSub} />
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<SubComponent /> <SubComponent
key={`${activeParent}-${activeSubId}-${JSON.stringify(subProps)}`}
onNavigate={navigateTo}
{...subProps}
/>
</div> </div>
</div> </div>
) )

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import client from '../../../api/client' import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar' import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives' import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import DrillDownModal from '../shared/DrillDownModal' import OrderDetailModal from '../shared/OrderDetailModal'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable' import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton' import ExportButton from '../shared/ExportButton'
@@ -20,11 +20,11 @@ const STATUS_OPTIONS = [
{ value: 'cancelled', label: 'Ακυρωμένη' }, { value: 'cancelled', label: 'Ακυρωμένη' },
] ]
export default function OrderHistory() { export default function OrderHistory({ initialBusinessDayId } = {}) {
const [mode, setMode] = useState('range') const [mode, setMode] = useState(initialBusinessDayId ? 'workday' : 'range')
const [from, setFrom] = useState(monthAgo()) const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today()) const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all') const [businessDayId, setBusinessDayId] = useState(initialBusinessDayId ? String(initialBusinessDayId) : 'all')
const [statusF, setStatusF] = useState('all') const [statusF, setStatusF] = useState('all')
const [waiterF, setWaiterF] = useState('all') const [waiterF, setWaiterF] = useState('all')
const [tableF, setTableF] = useState('all') const [tableF, setTableF] = useState('all')
@@ -95,12 +95,12 @@ export default function OrderHistory() {
</THead> </THead>
<tbody> <tbody>
{orders.slice(0, 200).map(o => { {orders.slice(0, 200).map(o => {
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0) const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0)
const isCancelled = o.status === 'cancelled' const isCancelled = o.status === 'cancelled'
return ( return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}> <TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
<TD mono>#{o.id}</TD> <TD mono>#{o.id}</TD>
<TD>{o.table_id}</TD> <TD>{o.table_name ?? o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD> <TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{fmtDateTime(o.closed_at)}</TD> <TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD><StatusBadge status={o.status} /></TD> <TD><StatusBadge status={o.status} /></TD>
@@ -130,45 +130,7 @@ export default function OrderHistory() {
</div> </div>
{drillOrder && ( {drillOrder && (
<DrillDownModal <OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
title={`Order #${drillOrder.id}`}
subtitle={`${fmtDateTime(drillOrder.opened_at)} · Τραπέζι ${drillOrder.table_id}`}
onClose={() => setDrillOrder(null)}
>
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-4 flex items-center gap-3">
<StatusBadge status={drillOrder.status} />
{drillOrder.notes && (
<span className="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-800 ring-1 ring-inset ring-amber-200">
Σημείωση: {drillOrder.notes}
</span>
)}
</div>
<DataTable>
<THead>
<TH>Προϊόν</TH><TH align="right">Ποσ.</TH><TH align="right">Τιμή</TH><TH align="right">Υποσύνολο</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{(drillOrder.items || []).map((item, i) => (
<TR key={i} striped>
<TD className="font-medium">{item.product?.name ?? `#${item.product_id}`}</TD>
<TD mono align="right">×{item.quantity}</TD>
<TD mono align="right">{fmtEUR(item.unit_price)}</TD>
<TD mono align="right" className="font-semibold">{fmtEUR(item.unit_price * item.quantity)}</TD>
<TD><StatusBadge status={item.status} /></TD>
</TR>
))}
<tr className="bg-slate-50">
<TD colSpan={3} className="py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-slate-500">Σύνολο</TD>
<TD mono align="right" className="py-3 text-base font-semibold text-slate-900">
{fmtEUR((drillOrder.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0))}
</TD>
<TD />
</tr>
</tbody>
</DataTable>
</div>
</DrillDownModal>
)} )}
</div> </div>
) )

View File

@@ -1,39 +1,208 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client' import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar' import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, ChartTooltip } from '../shared/TablePrimitives' import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import DrillDownModal from '../shared/DrillDownModal' import DrillDownModal from '../shared/DrillDownModal'
import OrderDetailModal from '../shared/OrderDetailModal'
import ShiftDetailModal from '../shared/ShiftDetailModal'
import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable' import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton' import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration } from '../shared/reportDesignTokens' import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) } function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) } function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ── Workday drill-down modal ────────────────────────────────────────────────
function WorkDayModal({ day, onClose, onDeleteShift }) {
const [tab, setTab] = useState('orders')
const [drillOrder, setDrillOrder] = useState(null)
const [detailShift, setDetailShift] = useState(null)
const { data: ordersData } = useQuery({
queryKey: ['business-day-orders', day.id],
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: day.id, page_size: 200 } }).then(r => r.data),
staleTime: 0,
})
const { data: shiftsData } = useQuery({
queryKey: ['shifts-for-day', day.id],
queryFn: () => client.get('/api/reports/shifts', { params: { business_day_id: day.id } }).then(r => r.data),
staleTime: 0,
})
const orders = Array.isArray(ordersData) ? ordersData : []
const shifts = shiftsData?.shifts || []
const TABS = [
{ key: 'orders', label: `Παραγγελίες (${orders.length})` },
{ key: 'shifts', label: `Βάρδιες (${shifts.length})` },
]
return (
<>
<DrillDownModal
title={`Εργάσιμη Μέρα · ${fmtDate(day.opened_at)}`}
subtitle={`${fmtEUR(day.revenue)} έσοδα · ${fmtTime(day.opened_at)} ${day.closed_at ? fmtTime(day.closed_at) : 'ανοιχτή'}`}
onClose={onClose}
>
{/* Tabs */}
<div className="flex border-b border-slate-200 px-6">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${tab === t.key ? 'border-slate-800 text-slate-900' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
{t.label}
</button>
))}
</div>
{/* Orders tab */}
{tab === 'orders' && (
orders.length === 0
? <div className="py-12 text-center text-slate-400 text-sm">Δεν βρέθηκαν παραγγελίες</div>
: <DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{orders.map(o => {
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0)
return (
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
<TD mono>#{o.id}</TD>
<TD>{o.table_name ?? o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
<TD mono align="right">{(o.items || []).length}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD><StatusBadge status={o.status} /></TD>
</TR>
)
})}
</tbody>
</DataTable>
)}
{/* Shifts tab */}
{tab === 'shifts' && (
shifts.length === 0
? <div className="py-12 text-center text-slate-400 text-sm">Δεν βρέθηκαν βάρδιες</div>
: <DataTable>
<THead>
<TH>Σερβιτόρος</TH>
<TH>Έναρξη</TH>
<TH>Λήξη</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Εισπράχθηκαν</TH>
<TH>Κατάσταση</TH>
<TH className="w-20" />
</THead>
<tbody>
{shifts.map(s => (
<TR key={s.id} striped onClick={() => setDetailShift(s)} className="cursor-pointer">
<TD><WaiterAvatar name={s.waiter_name} id={s.waiter_id} /></TD>
<TD mono>{fmtDateTime(s.started_at)}</TD>
<TD mono>{s.ended_at ? fmtDateTime(s.ended_at) : <span className="text-sky-600 font-medium"> ενεργή </span>}</TD>
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right">
<div className="flex items-center justify-end gap-2">
<button
onClick={e => { e.stopPropagation(); setDetailShift(s) }}
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
>
Λεπτομέρειες
</button>
{!s.is_active && (
<button
onClick={e => { e.stopPropagation(); onDeleteShift(s) }}
className="rounded border border-red-200 bg-white p-0.5 text-red-400 hover:bg-red-50 hover:text-red-600"
title="Διαγραφή βάρδιας"
>
<Trash2 size={13} />
</button>
)}
</div>
</TD>
</TR>
))}
</tbody>
</DataTable>
)}
</DrillDownModal>
{drillOrder && (
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
)}
{detailShift && (
<ShiftDetailModal
shiftId={detailShift.id}
shiftWaiterId={detailShift.waiter_id}
onClose={() => setDetailShift(null)}
/>
)}
</>
)
}
// ── Main page ───────────────────────────────────────────────────────────────
export default function WorkDaySummary() { export default function WorkDaySummary() {
const [from, setFrom] = useState(monthAgo()) const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today()) const [to, setTo] = useState(today())
const [drillId, setDrillId] = useState(null) const [drillDay, setDrillDay] = useState(null)
const [deleteDay, setDeleteDay] = useState(null)
const [deleteShift, setDeleteShift] = useState(null)
const qc = useQueryClient()
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['business-days', from, to], queryKey: ['business-days', from, to],
queryFn: () => client.get('/api/reports/business-days', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data), queryFn: () => client.get('/api/reports/business-days', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data),
staleTime: 60 * 1000, staleTime: 0,
}) })
const { data: drillData } = useQuery({ const deleteDayMutation = useMutation({
queryKey: ['business-day-orders', drillId], mutationFn: (id) => client.delete(`/api/business-day/${id}`),
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: drillId, page_size: 200 } }).then(r => r.data), onSuccess: () => {
enabled: !!drillId, toast.success('Η εργάσιμη μέρα διαγράφηκε')
staleTime: 60 * 1000, qc.invalidateQueries({ queryKey: ['business-days'] })
qc.invalidateQueries({ queryKey: ['business-days-list'] })
setDeleteDay(null)
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής εργάσιμης μέρας')
setDeleteDay(null)
},
})
const deleteShiftMutation = useMutation({
mutationFn: (id) => client.delete(`/api/shifts/${id}`),
onSuccess: () => {
toast.success('Η βάρδια και οι παραγγελίες της διαγράφηκαν')
qc.invalidateQueries({ queryKey: ['shifts'] })
qc.invalidateQueries({ queryKey: ['shifts-for-day'] })
qc.invalidateQueries({ queryKey: ['business-days'] })
qc.invalidateQueries({ queryKey: ['business-days-list'] })
setDeleteShift(null)
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής βάρδιας')
setDeleteShift(null)
},
}) })
const days = data?.business_days || [] const days = data?.business_days || []
const drillDay = drillId ? days.find(d => d.id === drillId) : null
const drillOrders = Array.isArray(drillData) ? drillData : []
const chartData = [...days].reverse().map(d => ({ const chartData = [...days].reverse().map(d => ({
date: fmtDate(d.opened_at), date: fmtDate(d.opened_at),
@@ -90,10 +259,11 @@ export default function WorkDaySummary() {
<TH align="right">Ακυρώσεις</TH> <TH align="right">Ακυρώσεις</TH>
<TH align="right">Σερβιτόροι</TH> <TH align="right">Σερβιτόροι</TH>
<TH>Κατάσταση</TH> <TH>Κατάσταση</TH>
<TH className="w-10" />
</THead> </THead>
<tbody> <tbody>
{days.map(d => ( {days.map(d => (
<TR key={d.id} onClick={() => setDrillId(d.id)} striped> <TR key={d.id} onClick={() => setDrillDay(d)} striped>
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD> <TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
<TD mono>{fmtTime(d.opened_at)}</TD> <TD mono>{fmtTime(d.opened_at)}</TD>
<TD mono>{d.closed_at ? fmtTime(d.closed_at) : '—'}</TD> <TD mono>{d.closed_at ? fmtTime(d.closed_at) : '—'}</TD>
@@ -103,6 +273,18 @@ export default function WorkDaySummary() {
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD> <TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
<TD mono align="right">{fmtNum(d.waiter_count)}</TD> <TD mono align="right">{fmtNum(d.waiter_count)}</TD>
<TD><StatusBadge status={d.status} pulse /></TD> <TD><StatusBadge status={d.status} pulse /></TD>
<TD align="right">
{d.status === 'closed' && (
<button
onClick={e => { e.stopPropagation(); setDeleteDay(d) }}
className={`rounded border p-0.5 bg-white ${(d.shift_count ?? 0) === 0 ? 'border-red-200 text-red-400 hover:bg-red-50 hover:text-red-600' : 'border-slate-200 text-slate-300 cursor-not-allowed'}`}
title={(d.shift_count ?? 0) === 0 ? 'Διαγραφή εργάσιμης μέρας' : `Έχει ${d.shift_count} βάρδιες — διαγράψτε τες πρώτα`}
disabled={(d.shift_count ?? 0) > 0}
>
<Trash2 size={13} />
</button>
)}
</TD>
</TR> </TR>
))} ))}
</tbody> </tbody>
@@ -113,32 +295,29 @@ export default function WorkDaySummary() {
</div> </div>
{drillDay && ( {drillDay && (
<DrillDownModal <WorkDayModal
title={`Εργάσιμη Μέρα · ${fmtDate(drillDay.opened_at)}`} day={drillDay}
subtitle={`${drillOrders.length} παραγγελίες · ${fmtEUR(drillDay.revenue)} έσοδα`} onClose={() => setDrillDay(null)}
onClose={() => setDrillId(null)} onDeleteShift={s => setDeleteShift(s)}
> />
<DataTable> )}
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH> {deleteDay && (
</THead> <DeleteConfirmModal
<tbody> title={`Διαγραφή Εργάσιμης Μέρας #${deleteDay.id}`}
{drillOrders.map(o => { description={`Η εργάσιμη μέρα της ${fmtDate(deleteDay.opened_at)} θα διαγραφεί μόνιμα. Δεν έχει βάρδιες.`}
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0) onConfirm={() => deleteDayMutation.mutate(deleteDay.id)}
return ( onCancel={() => setDeleteDay(null)}
<TR key={o.id} striped> />
<TD mono>#{o.id}</TD> )}
<TD>{o.table_id}</TD>
<TD mono>{fmtDate(o.opened_at)} {fmtTime(o.opened_at)}</TD> {deleteShift && (
<TD mono>{o.closed_at ? fmtTime(o.closed_at) : '—'}</TD> <DeleteConfirmModal
<TD mono align="right" className="font-semibold">{fmtEUR(total)}</TD> title={`Διαγραφή Βάρδιας #${deleteShift.id}`}
<TD><StatusBadge status={o.status} /></TD> description={`Η βάρδια του ${deleteShift.waiter_name} (${fmtDateTime(deleteShift.started_at)}) και ΟΛΕΣ οι παραγγελίες της θα διαγραφούν μόνιμα.`}
</TR> onConfirm={() => deleteShiftMutation.mutate(deleteShift.id)}
) onCancel={() => setDeleteShift(null)}
})} />
</tbody>
</DataTable>
</DrillDownModal>
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,286 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { fmtEUR, fmtDateTime } from './reportDesignTokens'
// ── Status badge ────────────────────────────────────────────────────────────
const STATUS_META = {
active: { label: 'Ενεργό', bg: '#eff6ff', text: '#1d4ed8' },
paid: { label: 'Πληρωμένο', bg: '#f0fdf4', text: '#15803d' },
cancelled: { label: 'Ακυρωμένο', bg: '#fef2f2', text: '#b91c1c' },
closed: { label: 'Κλειστό (απλήρωτο)', bg: '#fffbeb', text: '#b45309' },
open: { label: 'Ανοιχτή', bg: '#eff6ff', text: '#1d4ed8' },
partially_paid: { label: 'Μερική πλ.', bg: '#fffbeb', text: '#b45309' },
}
function StatusPill({ status }) {
const m = STATUS_META[status] || { label: status, bg: '#f9fafb', text: '#374151' }
return (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 20, background: m.bg, color: m.text }}>
{m.label}
</span>
)
}
// ── Payment method chip ─────────────────────────────────────────────────────
const PAY_METHOD_STYLES = {
cash: { label: 'ΜΕΤΡΗΤΑ', color: '#15803d', bg: '#f0fdf4', border: '#86efac' },
card: { label: 'ΚΑΡΤΑ', color: '#1d4ed8', bg: '#eff6ff', border: '#93c5fd' },
transfer: { label: 'ΤΡΑΠΕΖΙΚΗ', color: '#6d28d9', bg: '#f5f3ff', border: '#c4b5fd' },
treat: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
comp: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
}
function PayMethodChip({ method }) {
if (!method) return <span style={{ color: '#b8bdc4', fontSize: 12 }}></span>
const s = PAY_METHOD_STYLES[method] || { label: method.toUpperCase(), color: '#374151', bg: '#f3f4f6', border: '#d1d5db' }
return (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 6, background: s.bg, color: s.color, border: `1px solid ${s.border}`, letterSpacing: 0.3, whiteSpace: 'nowrap' }}>
{s.label}
</span>
)
}
// ── Audit event labels ──────────────────────────────────────────────────────
const EVENT_LABELS = {
ORDER_OPENED: { label: 'Άνοιγμα', bg: '#eff6ff', text: '#1d4ed8' },
ITEMS_ADDED: { label: 'Προσθήκη', bg: '#eff6ff', text: '#1d4ed8' },
PAYMENT: { label: 'Πληρωμή', bg: '#f0fdf4', text: '#15803d' },
PAYMENT_OFFLINE: { label: 'Πληρωμή (Offline)', bg: '#fffbeb', text: '#b45309' },
ORDER_CLOSED: { label: 'Κλείσιμο', bg: '#f9fafb', text: '#374151' },
ORDER_CANCELLED: { label: 'Ακύρωση', bg: '#fef2f2', text: '#b91c1c' },
ITEM_CANCELLED: { label: 'Ακύρωση αντ.', bg: '#fef2f2', text: '#b91c1c' },
}
function AuditTimeline({ logs, itemsById }) {
if (!logs || logs.length === 0) {
return <p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '16px 0' }}>Δεν υπάρχουν εγγραφές</p>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{logs.map(log => {
const meta = EVENT_LABELS[log.event_type] || { label: log.event_type, bg: '#f9fafb', text: '#374151' }
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
const displayTime = log.offline_at ? fmtDateTime(log.offline_at) : fmtDateTime(log.created_at)
let itemNames = []
if (log.item_ids && itemsById) {
try {
const ids = JSON.parse(log.item_ids)
itemNames = ids.map(id => {
const item = itemsById[id]
return item ? `${item.product?.name ?? `#${item.product_id}`} ×${item.quantity}` : null
}).filter(Boolean)
} catch { /* ignore */ }
}
return (
<div key={log.id} style={{
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '8px 0',
borderBottom: '1px solid #f4f4f2',
background: isDuplicate ? '#fef2f2' : 'transparent',
}}>
<div style={{ flexShrink: 0, paddingTop: 2 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 10, background: isDuplicate ? '#fecaca' : meta.bg, color: isDuplicate ? '#b91c1c' : meta.text }}>
{meta.label}
</span>
{isDuplicate && <div style={{ fontSize: 10, color: '#dc2626', fontWeight: 700, marginTop: 2 }}>ΔΙΠΛΗ</div>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: '#374151', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span>{log.waiter_name ?? (log.waiter_id ? `#${log.waiter_id}` : '—')}</span>
{log.amount != null && (
<span style={{ fontWeight: 700, color: isDuplicate ? '#dc2626' : '#2f9e5e' }}>
{fmtEUR(log.amount)}
</span>
)}
{log.payment_method && <PayMethodChip method={log.payment_method} />}
</div>
{itemNames.length > 0 && (
<div style={{ marginTop: 3, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{itemNames.map((name, i) => (
<span key={i} style={{ fontSize: 11, background: '#f3f4f6', color: '#374151', padding: '1px 6px', borderRadius: 4 }}>
{name}
</span>
))}
</div>
)}
</div>
<div style={{ fontSize: 11, color: '#9ca3af', textAlign: 'right', flexShrink: 0 }}>
{displayTime}
{log.offline_at && <div style={{ fontSize: 10, color: '#f59e0b' }}>offline</div>}
</div>
</div>
)
})}
</div>
)
}
// ── Items tab ───────────────────────────────────────────────────────────────
function ItemsTab({ order }) {
const itemsFiltered = order.items || []
const billableItems = itemsFiltered.filter(i => i.status !== 'cancelled')
const closedTotal = billableItems.filter(i => i.status === 'closed').reduce((s, i) => s + i.unit_price * i.quantity, 0)
const total = billableItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
if (itemsFiltered.length === 0) {
return <p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '12px 0' }}>Κανένα αντικείμενο</p>
}
// Column widths: [item info] [ordered by] [paid by] [pay type] [total]
const GRID = '1fr 150px 150px 110px 80px'
return (
<div style={{ borderRadius: 10, border: '1px solid #edeff1', overflow: 'hidden', marginBottom: 16 }}>
{/* Header */}
<div style={{ display: 'grid', gridTemplateColumns: GRID, background: '#f9fafb', padding: '6px 14px', fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.4, gap: 12 }}>
<span>Προϊόν</span>
<span>Παρήγγειλε</span>
<span>Πλήρωσε</span>
<span>Τρόπος</span>
<span style={{ textAlign: 'right' }}>Σύνολο</span>
</div>
{itemsFiltered.map(item => {
const isCancelled = item.status === 'cancelled'
return (
<div key={item.id} style={{
display: 'grid', gridTemplateColumns: GRID, gap: 12,
padding: '9px 14px', borderTop: '1px solid #f4f4f2',
opacity: isCancelled ? 0.45 : 1,
background: isCancelled ? '#fef2f2' : item.status === 'closed' ? '#fffbeb' : 'white',
}}>
{/* Item info */}
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', textDecoration: isCancelled ? 'line-through' : 'none' }}>
{item.product?.name ?? `#${item.product_id}`}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#8a9099', fontFamily: 'ui-monospace,monospace' }}>×{item.quantity}</span>
<span style={{ fontSize: 12, color: '#374151', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(item.unit_price)}</span>
<StatusPill status={item.status} />
</div>
</div>
{/* Ordered by */}
<div style={{ fontSize: 11, paddingTop: 2 }}>
<div style={{ fontWeight: 600, color: '#374151' }}>{item.added_by_name ?? '—'}</div>
{item.added_at && <div style={{ color: '#9ca3af', marginTop: 1 }}>{fmtDateTime(item.added_at)}</div>}
</div>
{/* Paid by */}
<div style={{ fontSize: 11, paddingTop: 2 }}>
{item.paid_by_name
? <>
<div style={{ fontWeight: 600, color: '#15803d' }}>{item.paid_by_name}</div>
{item.paid_at && <div style={{ color: '#9ca3af', marginTop: 1 }}>{fmtDateTime(item.paid_at)}</div>}
</>
: <span style={{ color: '#b8bdc4' }}></span>
}
</div>
{/* Pay type */}
<div style={{ paddingTop: 2 }}>
<PayMethodChip method={item.payment_method} />
</div>
{/* Total */}
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', textAlign: 'right', fontFamily: 'ui-monospace,monospace', paddingTop: 2 }}>
{fmtEUR(item.unit_price * item.quantity)}
</div>
</div>
)
})}
{/* Totals footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '8px 14px', background: '#f9fafb', borderTop: '1px solid #edeff1', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{closedTotal > 0 && (
<span style={{ fontSize: 11, color: '#b45309' }}>
Απλήρωτα κλειστά: <span style={{ fontWeight: 700 }}>{fmtEUR(closedTotal)}</span>
</span>
)}
<span style={{ fontSize: 11, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.4 }}>Σύνολο</span>
<span style={{ fontSize: 15, fontWeight: 700, color: '#111315', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(total)}</span>
</div>
</div>
)
}
// ── Main modal ──────────────────────────────────────────────────────────────
export default function OrderDetailModal({ order, onClose }) {
const [tab, setTab] = useState('items')
if (!order) return null
const itemsById = Object.fromEntries((order.items || []).map(i => [i.id, i]))
const TABS = [
{ key: 'items', label: `Αντικείμενα (${(order.items || []).length})` },
{ key: 'audit', label: 'Ιστορικό Συναλλαγών' },
]
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
onClick={onClose}
>
<div
style={{ background: 'white', borderRadius: 20, width: '100%', maxWidth: 1032, maxHeight: '92vh', display: 'flex', flexDirection: 'column', boxShadow: '0 24px 64px rgba(0,0,0,0.25)' }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ padding: '18px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 17, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 10 }}>
Παραγγελία #{order.id}
<StatusPill status={order.status} />
</div>
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 3 }}>
{order.table_name ? `Τραπέζι ${order.table_name}` : order.table_id ? `Τραπέζι #${order.table_id}` : ''}
{order.notes && <span style={{ marginLeft: 8, color: '#d97706' }}>· Σημ: {order.notes}</span>}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#8a9099', padding: 4 }}>
<X size={18} />
</button>
</div>
{/* Opener / closer strip */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: '#edeff1', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
{[
{ label: 'Άνοιξε', name: order.opened_by_name, time: order.opened_at },
{ label: 'Έκλεισε', name: order.closed_by_name, time: order.closed_at },
].map(cell => (
<div key={cell.label} style={{ background: 'white', padding: '10px 20px' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>{cell.label}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: cell.name ? '#111315' : '#b8bdc4' }}>{cell.name ?? '—'}</div>
{cell.time && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{fmtDateTime(cell.time)}</div>}
</div>
))}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #edeff1', flexShrink: 0, padding: '0 24px' }}>
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
style={{
padding: '10px 16px', fontSize: 12, fontWeight: 600,
color: tab === t.key ? '#111315' : '#8a9099',
borderBottom: tab === t.key ? '2px solid #111315' : '2px solid transparent',
background: 'none', border: 'none', borderBottom: tab === t.key ? '2px solid #111315' : '2px solid transparent',
cursor: 'pointer', transition: 'color 0.15s',
}}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{tab === 'items' && <ItemsTab order={order} />}
{tab === 'audit' && <AuditTimeline logs={order.audit_logs} itemsById={itemsById} />}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,263 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { X } from 'lucide-react'
import client from '../../../api/client'
import { fmtEUR, fmtDateTime, fmtTime, fmtDate } from './reportDesignTokens'
// ── colour rules ────────────────────────────────────────────────────────────
// Given the shift's waiter_id and one item, return { key, label, colors }
function classify(item, shiftWaiterId) {
const orderedByMe = item.added_by_id === shiftWaiterId
const paidToMe = item.paid_by_id === shiftWaiterId
const isPaid = item.status === 'paid'
if (orderedByMe && paidToMe && isPaid)
return { key: 'both', label: 'Παρήγγειλε + Πληρώθηκε', dot: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' }
if (!orderedByMe && paidToMe && isPaid)
return { key: 'paid', label: 'Πληρώθηκε (άλλος παρήγγειλε)', dot: '#2563eb', bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' }
if (orderedByMe && isPaid && !paidToMe)
return { key: 'ordered', label: 'Παρήγγειλε (Πληρώθηκε άλλος)', dot: '#ca8a04', bg: '#fefce8', border: '#fef08a', text: '#854d0e' }
if (orderedByMe && !isPaid)
return { key: 'unpaid', label: 'Παρήγγειλε (απλήρωτο)', dot: '#ea580c', bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' }
// fallback: paid to me but status not 'paid' — data anomaly
return { key: 'anomaly', label: 'Ανωμαλία δεδομένων', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' }
}
const FILTER_OPTIONS = [
{ key: 'all', label: 'Όλα' },
{ key: 'both', label: 'Παρήγγειλε + Πληρώθηκε' },
{ key: 'paid', label: 'Πληρώθηκε (ξένη παραγγελία)' },
{ key: 'ordered', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' },
{ key: 'unpaid', label: 'Απλήρωτα' },
]
function fmtMins(mins) {
if (mins == null) return '—'
const h = Math.floor(mins / 60)
const m = mins % 60
return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
}
function ItemRow({ item, shiftWaiterId }) {
const c = classify(item, shiftWaiterId)
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '7px 14px',
borderTop: '1px solid #f4f4f2', background: c.bg,
}}>
{/* colour dot */}
<div style={{ width: 8, height: 8, borderRadius: '50%', background: c.dot, flexShrink: 0, marginTop: 5 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
{item.product_name}
<span style={{ fontWeight: 400, color: '#8a9099', marginLeft: 4 }}>×{item.quantity}</span>
</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#111315', flexShrink: 0 }}>{fmtEUR(item.subtotal)}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '2px 12px', marginTop: 2 }}>
<span style={{ fontSize: 11, color: '#5a6169' }}>
Παρήγγειλε: <span style={{ fontWeight: 600, color: '#374151' }}>{item.added_by_name ?? '—'}</span>
{item.added_at ? <span style={{ color: '#9ca3af' }}> · {fmtDateTime(item.added_at)}</span> : null}
</span>
<span style={{ fontSize: 11, color: '#5a6169' }}>
Πληρώθηκε: <span style={{ fontWeight: 600, color: item.paid_by_name ? '#374151' : '#9ca3af' }}>{item.paid_by_name ?? '—'}</span>
{item.paid_at ? <span style={{ color: '#9ca3af' }}> · {fmtDateTime(item.paid_at)}</span> : null}
{item.payment_method ? <span style={{ color: '#9ca3af' }}> ({item.payment_method})</span> : null}
</span>
</div>
<div style={{ marginTop: 2 }}>
<span style={{
fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4,
background: c.border, color: c.text, border: `1px solid ${c.border}`,
}}>{c.label}</span>
</div>
</div>
</div>
)
}
function OrderGroup({ order, shiftWaiterId, activeFilter }) {
const visibleItems = order.items.filter(item => {
if (activeFilter === 'all') return true
return classify(item, shiftWaiterId).key === activeFilter
})
if (visibleItems.length === 0) return null
const groupTotal = visibleItems
.filter(i => i.status === 'paid')
.reduce((s, i) => s + i.subtotal, 0)
return (
<div style={{ borderRadius: 10, border: '1px solid #edeff1', marginBottom: 8, overflow: 'hidden' }}>
{/* Order header */}
<div style={{ padding: '8px 14px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
Παραγγελία #{order.order_id}
{order.table_name && <span style={{ color: '#8a9099', fontWeight: 400, marginLeft: 6 }}>· Τραπέζι {order.table_name}</span>}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{order.opener_name && (
<span style={{ fontSize: 11, color: '#5a6169' }}>
Άνοιξε: <span style={{ fontWeight: 600, color: '#374151' }}>{order.opener_name}</span>
{order.opened_at ? <span style={{ color: '#9ca3af' }}> {fmtTime(order.opened_at)}</span> : null}
</span>
)}
{order.closer_name && (
<span style={{ fontSize: 11, color: '#5a6169' }}>
Έκλεισε: <span style={{ fontWeight: 600, color: '#374151' }}>{order.closer_name}</span>
{order.closed_at ? <span style={{ color: '#9ca3af' }}> {fmtTime(order.closed_at)}</span> : null}
</span>
)}
{groupTotal > 0 && (
<span style={{ fontSize: 12, fontWeight: 700, color: '#2f9e5e' }}>{fmtEUR(groupTotal)}</span>
)}
</div>
</div>
{visibleItems.map(item => (
<ItemRow key={item.id} item={item} shiftWaiterId={shiftWaiterId} />
))}
</div>
)
}
export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
const [activeFilter, setActiveFilter] = useState('all')
const { data: summary, isLoading, isError } = useQuery({
queryKey: ['shift-detail', shiftId],
queryFn: () => client.get(`/api/shifts/${shiftId}/summary`).then(r => r.data),
staleTime: 30 * 1000,
})
// Count items per filter key for badge counts
const counts = { all: 0, both: 0, paid: 0, ordered: 0, unpaid: 0 }
const waiterIdToUse = shiftWaiterId ?? summary?.waiter_id
if (summary?.orders) {
for (const o of summary.orders) {
for (const item of o.items) {
counts.all++
const key = classify(item, waiterIdToUse).key
if (key in counts) counts[key]++
}
}
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}}
onClick={onClose}
>
<div style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 900,
maxHeight: '92vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 24px 64px rgba(0,0,0,0.25)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ padding: '18px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 17, fontWeight: 700, color: '#111315' }}>Λεπτομέρειες Βάρδιας</div>
{summary && (
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>
{summary.waiter_name} · {fmtDate(summary.started_at)} · {fmtTime(summary.started_at)}{summary.ended_at ? fmtTime(summary.ended_at) : 'ενεργή'}
</div>
)}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#8a9099', padding: 4 }}>
<X size={18} />
</button>
</div>
{isLoading && <div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Φόρτωση</div>}
{isError && <div style={{ padding: 40, textAlign: 'center', color: '#dc2626' }}>Σφάλμα φόρτωσης δεδομένων</div>}
{summary && (
<>
{/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
{[
{ label: 'Διάρκεια', value: fmtMins(summary.duration_minutes) },
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
{ label: 'Είσπραξη', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
{ label: 'Προς παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
].map(k => (
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
</div>
))}
</div>
{/* Colour legend */}
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
{[
{ dot: '#16a34a', label: 'Παρήγγειλε + Πληρώθηκε' },
{ dot: '#2563eb', label: 'Πληρώθηκε (παρήγγηλε άλλος)' },
{ dot: '#ca8a04', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' },
{ dot: '#ea580c', label: 'Παρήγγειλε (απλήρωτο)' },
{ dot: '#dc2626', label: 'Πρόβλημα Δεδομένων' },
].map(l => (
<div key={l.dot} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#5a6169' }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.dot, flexShrink: 0 }} />
{l.label}
</div>
))}
</div>
{/* Filter bar */}
<div style={{ display: 'flex', gap: 6, padding: '10px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, overflowX: 'auto' }}>
{FILTER_OPTIONS.map(opt => (
<button
key={opt.key}
onClick={() => setActiveFilter(opt.key)}
style={{
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
border: activeFilter === opt.key ? '1.5px solid #3758c9' : '1.5px solid #e5e7eb',
background: activeFilter === opt.key ? '#eff6ff' : 'white',
color: activeFilter === opt.key ? '#3758c9' : '#5a6169',
}}
>
{opt.label}
{counts[opt.key] > 0 && (
<span style={{
marginLeft: 5, padding: '1px 5px', borderRadius: 8, fontSize: 10,
background: activeFilter === opt.key ? '#3758c9' : '#e5e7eb',
color: activeFilter === opt.key ? 'white' : '#5a6169',
}}>
{counts[opt.key]}
</span>
)}
</button>
))}
</div>
{/* Orders + items */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 24px' }}>
{summary.orders.length === 0 ? (
<p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
Δεν υπάρχουν αντικείμενα για αυτή τη βάρδια
</p>
) : (
summary.orders.map(o => (
<OrderGroup key={o.order_id} order={o} shiftWaiterId={waiterIdToUse} activeFilter={activeFilter} />
))
)}
{summary.orders.length > 0 &&
!summary.orders.some(o => o.items.some(i =>
activeFilter === 'all' || classify(i, waiterIdToUse).key === activeFilter
)) && (
<p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
Δεν υπάρχουν αντικείμενα για αυτό το φίλτρο
</p>
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { UserRoundX, ChevronRight } from 'lucide-react' import { UserRoundX, ChevronRight, ExternalLink, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import ShiftDetailModal from '../shared/ShiftDetailModal'
import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts'
import client from '../../../api/client' import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar' import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
@@ -9,18 +12,22 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable' import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton' import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtDateTime, fmtDuration } from '../shared/reportDesignTokens' import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) } function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { function monthAgo() {
const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10)
} }
export default function ShiftsOverview() { export default function ShiftsOverview({ onNavigate } = {}) {
const [waiterId, setWaiterId] = useState('all') const [waiterId, setWaiterId] = useState('all')
const [from, setFrom] = useState(monthAgo()) const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today()) const [to, setTo] = useState(today())
const [expanded, setExpanded] = useState(null) const [expanded, setExpanded] = useState(null)
const [detailShift, setDetailShift] = useState(null) // { id, waiter_id }
const [deleteShift, setDeleteShift] = useState(null) // shift object to delete
const qc = useQueryClient()
const { data: waitersData } = useQuery({ const { data: waitersData } = useQuery({
queryKey: ['meta-waiters'], queryKey: ['meta-waiters'],
@@ -28,6 +35,14 @@ export default function ShiftsOverview() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
const { data: bdData } = useQuery({
queryKey: ['business-days-list'],
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
staleTime: 60 * 1000,
})
const bdById = Object.fromEntries((bdData?.business_days || []).map(bd => [String(bd.id), bd]))
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['shifts', waiterId, from, to], queryKey: ['shifts', waiterId, from, to],
queryFn: () => client.get('/api/reports/shifts', { queryFn: () => client.get('/api/reports/shifts', {
@@ -37,7 +52,21 @@ export default function ShiftsOverview() {
to: to + 'T23:59:59', to: to + 'T23:59:59',
}, },
}).then(r => r.data), }).then(r => r.data),
staleTime: 60 * 1000, staleTime: 0,
})
const deleteShiftMutation = useMutation({
mutationFn: (id) => client.delete(`/api/shifts/${id}`),
onSuccess: () => {
toast.success('Η βάρδια διαγράφηκε')
qc.invalidateQueries({ queryKey: ['shifts'] })
qc.invalidateQueries({ queryKey: ['business-days-list'] })
setDeleteShift(null)
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής βάρδιας')
setDeleteShift(null)
},
}) })
const waiterOptions = [ const waiterOptions = [
@@ -94,7 +123,7 @@ export default function ShiftsOverview() {
<TH align="right">Εισπράχθηκαν</TH> <TH align="right">Εισπράχθηκαν</TH>
<TH align="right">Οφείλει</TH> <TH align="right">Οφείλει</TH>
<TH>Κατάσταση</TH> <TH>Κατάσταση</TH>
<TH className="w-10" /> <TH className="w-28" />
</THead> </THead>
<tbody> <tbody>
{shifts.map(s => { {shifts.map(s => {
@@ -119,14 +148,49 @@ export default function ShiftsOverview() {
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD> <TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD> <TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right"> <TD align="right">
<ChevronRight className={`h-4 w-4 text-slate-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} /> <div className="flex items-center justify-end gap-2">
<button
onClick={e => { e.stopPropagation(); setDetailShift({ id: s.id, waiter_id: s.waiter_id }) }}
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
>
Λεπτομέρειες
</button>
{!s.is_active && (
<button
onClick={e => { e.stopPropagation(); setDeleteShift(s) }}
className="rounded border border-red-200 bg-white p-0.5 text-red-400 hover:bg-red-50 hover:text-red-600"
title="Διαγραφή βάρδιας"
>
<Trash2 size={13} />
</button>
)}
<ChevronRight className={`h-4 w-4 text-slate-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</div>
</TD> </TD>
</TR>, </TR>,
isOpen && ( isOpen && (
<tr key={`${s.id}-detail`}> <tr key={`${s.id}-detail`}>
<td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3"> <td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<div className="text-[12px] text-slate-500"> <div className="flex items-center gap-4 text-[12px] text-slate-500">
Εργάσιμη Μέρα ID: {s.business_day_id ?? '—'} · Σημειώσεις: {s.notes || '—'} <span>
{'Εργάσιμη Μέρα: '}
{s.business_day_id ? (() => {
const bd = bdById[String(s.business_day_id)]
const label = bd
? `#${s.business_day_id} · ${fmtDate(bd.opened_at)} ${fmtTime(bd.opened_at)}`
: `#${s.business_day_id}`
return onNavigate ? (
<button
onClick={e => { e.stopPropagation(); onNavigate({ parent: 'restaurant', sub: 'orders', initialBusinessDayId: s.business_day_id }) }}
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 hover:underline font-medium"
>
{label}
<ExternalLink className="h-3 w-3" />
</button>
) : <span>{label}</span>
})() : '—'}
</span>
<span>Σημειώσεις: {s.notes || '—'}</span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -138,6 +202,23 @@ export default function ShiftsOverview() {
)} )}
</Panel> </Panel>
</div> </div>
{detailShift && (
<ShiftDetailModal
shiftId={detailShift.id}
shiftWaiterId={detailShift.waiter_id}
onClose={() => setDetailShift(null)}
/>
)}
{deleteShift && (
<DeleteConfirmModal
title={`Διαγραφή Βάρδιας #${deleteShift.id}`}
description={`Η βάρδια του ${deleteShift.waiter_name} (${fmtDateTime(deleteShift.started_at)} ${fmtDateTime(deleteShift.ended_at)}) θα διαγραφεί μόνιμα.`}
onConfirm={() => deleteShiftMutation.mutate(deleteShift.id)}
onCancel={() => setDeleteShift(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -11,6 +11,7 @@ const LABELS = {
partially_paid: 'Μερική πληρωμή', partially_paid: 'Μερική πληρωμή',
paid: 'Πληρώθηκε', paid: 'Πληρώθηκε',
closed: 'Κλειστό', closed: 'Κλειστό',
closed_item: 'Κλειστό (απλήρωτο)',
'force-closed': 'Αναγκαστικό κλείσιμο', 'force-closed': 'Αναγκαστικό κλείσιμο',
cancelled: 'Ακυρώθηκε', cancelled: 'Ακυρώθηκε',
completed: 'Ολοκληρώθηκε', completed: 'Ολοκληρώθηκε',

View File

@@ -0,0 +1,101 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
/**
* Two-step delete confirmation:
* Step 1 — "Are you sure?" with a description of what will be deleted.
* Step 2 — Type "DELETE" to confirm.
*
* Props:
* title — e.g. "Διαγραφή Βάρδιας #12"
* description — e.g. "Η βάρδια του Νίκου θα διαγραφεί μόνιμα."
* onConfirm — called when the user completes both steps
* onCancel — called when the user dismisses
*/
export default function DeleteConfirmModal({ title, description, onConfirm, onCancel }) {
const [step, setStep] = useState(1)
const [typed, setTyped] = useState('')
const canDelete = typed.trim().toUpperCase() === 'DELETE'
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/60 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-md rounded-2xl bg-white shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start gap-3 px-6 pt-6 pb-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100">
<AlertTriangle size={18} className="text-red-600" />
</div>
<div>
<div className="text-base font-bold text-gray-900">{title}</div>
<div className="mt-1 text-sm text-gray-500">{description}</div>
</div>
</div>
<div className="px-6 pb-6 space-y-4">
{step === 1 && (
<>
<p className="text-sm text-red-700 font-medium bg-red-50 rounded-lg px-3 py-2">
Αυτή η ενέργεια είναι μη αναστρέψιμη.
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 rounded-lg border border-gray-200 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
Ακύρωση
</button>
<button
onClick={() => setStep(2)}
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-bold text-white hover:bg-red-700"
>
Ναι, συνέχεια
</button>
</div>
</>
)}
{step === 2 && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Για επιβεβαίωση πληκτρολογήστε <span className="font-mono font-bold text-red-600">DELETE</span>
</label>
<input
autoFocus
type="text"
value={typed}
onChange={e => setTyped(e.target.value)}
onKeyDown={e => e.key === 'Enter' && canDelete && onConfirm()}
placeholder="DELETE"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-400"
/>
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 rounded-lg border border-gray-200 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
Ακύρωση
</button>
<button
onClick={onConfirm}
disabled={!canDelete}
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
Διαγραφή
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { X } from 'lucide-react'
const METHODS = [
{ key: 'cash', label: 'ΜΕΤΡΗΤΑ', icon: '💵', color: '#15803d', bg: '#f0fdf4', border: '#86efac' },
{ key: 'card', label: 'ΚΑΡΤΑ', icon: '💳', color: '#1d4ed8', bg: '#eff6ff', border: '#93c5fd' },
{ key: 'transfer', label: 'ΤΡΑΠΕΖΙΚΗ ΜΕΤΑΦΟΡΑ', icon: '🏦', color: '#6d28d9', bg: '#f5f3ff', border: '#c4b5fd' },
{ key: 'treat', label: 'ΚΕΡΑΣΤΗΚΕ', icon: '🎁', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
]
export default function PaymentMethodModal({ onSelect, onCancel, title = 'Τρόπος Πληρωμής;' }) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-2xl bg-white shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<span className="text-base font-bold text-gray-800">{title}</span>
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600 p-1">
<X size={16} />
</button>
</div>
{/* Method buttons */}
<div className="p-4 grid grid-cols-2 gap-3">
{METHODS.map(m => (
<button
key={m.key}
onClick={() => onSelect(m.key)}
style={{ background: m.bg, borderColor: m.border, color: m.color }}
className="flex flex-col items-center justify-center gap-2 rounded-xl border-2 py-5 font-bold text-sm transition-transform active:scale-95 hover:opacity-90"
>
<span className="text-2xl">{m.icon}</span>
{m.label}
</button>
))}
</div>
<div className="px-4 pb-4">
<button
onClick={onCancel}
className="w-full rounded-lg border border-gray-200 py-2 text-sm text-gray-500 hover:bg-gray-50"
>
Ακύρωση
</button>
</div>
</div>
</div>
)
}

View File

@@ -60,6 +60,7 @@ export const STATUS_STYLES = {
open: { bg: 'bg-sky-50', text: 'text-sky-700', ring: 'ring-sky-200', dot: 'bg-sky-500' }, open: { bg: 'bg-sky-50', text: 'text-sky-700', ring: 'ring-sky-200', dot: 'bg-sky-500' },
partially_paid: { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' }, partially_paid: { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' },
cancelled: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' }, cancelled: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' },
closed_item: { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' },
failed: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' }, failed: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' },
success: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' }, success: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' },
free: { bg: 'bg-slate-100', text: 'text-slate-600', ring: 'ring-slate-200', dot: 'bg-slate-400' }, free: { bg: 'bg-slate-100', text: 'text-slate-600', ring: 'ring-slate-200', dot: 'bg-slate-400' },

View File

@@ -8,6 +8,7 @@
"name": "waiter_pwa", "name": "waiter_pwa",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.100.11",
"axios": "^1.15.1", "axios": "^1.15.1",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"react": "^19.2.5", "react": "^19.2.5",
@@ -2250,6 +2251,32 @@
"string.prototype.matchall": "^4.0.6" "string.prototype.matchall": "^4.0.6"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.100.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz",
"integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz",
"integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.100.11",
"axios": "^1.15.1", "axios": "^1.15.1",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"react": "^19.2.5", "react": "^19.2.5",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import useAuthStore from './store/authStore' import useAuthStore from './store/authStore'
import useShiftStore from './store/shiftStore' import useShiftStore from './store/shiftStore'
import useThemeStore from './store/themeStore' import useThemeStore from './store/themeStore'
@@ -309,10 +310,21 @@ function ColourLoader() {
return null return null
} }
// ─── Login guard — redirect to /tables if already authenticated ───────────────
function LoginGuard({ children }) {
const { token } = useAuthStore()
if (token) return <Navigate to="/tables" replace />
return children
}
// ─── App ────────────────────────────────────────────────────────────────────── // ─── App ──────────────────────────────────────────────────────────────────────
const queryClient = new QueryClient()
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<ThemeApplier /> <ThemeApplier />
<ColourLoader /> <ColourLoader />
@@ -322,7 +334,7 @@ export default function App() {
<NotificationProvider> <NotificationProvider>
<ConnectionLostModal /> <ConnectionLostModal />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
<Route path="/offline" element={<OfflinePage />} /> <Route path="/offline" element={<OfflinePage />} />
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/tables" element={<TableListPage />} /> <Route path="/tables" element={<TableListPage />} />
@@ -335,5 +347,6 @@ export default function App() {
</NotificationProvider> </NotificationProvider>
</SSEProvider> </SSEProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider>
) )
} }

View File

@@ -3,7 +3,7 @@ import useConnectionStore from '../store/connectionStore'
import client from '../api/client' import client from '../api/client'
import { useSSEContext } from '../context/SSEContext' import { useSSEContext } from '../context/SSEContext'
const RETRY_INTERVAL = 10_000 // 10s auto-retry while modal is open in Wait mode const RETRY_INTERVAL = 10_000
export default function ConnectionLostModal() { export default function ConnectionLostModal() {
const { status, setOnline, enterEmergency } = useConnectionStore() const { status, setOnline, enterEmergency } = useConnectionStore()
@@ -11,13 +11,13 @@ export default function ConnectionLostModal() {
const [retrying, setRetrying] = useState(false) const [retrying, setRetrying] = useState(false)
const retryRef = useRef(null) const retryRef = useRef(null)
const isVisible = status === 'lost' const isReconnecting = status === 'reconnecting'
const isLost = status === 'lost'
async function tryReconnect() { async function tryReconnect() {
setRetrying(true) setRetrying(true)
try { try {
await client.get('/api/system/health') await client.get('/api/system/health')
// Server is back
setOnline() setOnline()
reconnect() reconnect()
await fullRefresh() await fullRefresh()
@@ -28,18 +28,53 @@ export default function ConnectionLostModal() {
} }
} }
// Auto-retry every 10s while modal is open // Auto-retry every 10s while the full "lost" modal is open
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isLost) { clearInterval(retryRef.current); return }
clearInterval(retryRef.current)
return
}
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL) retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
return () => clearInterval(retryRef.current) return () => clearInterval(retryRef.current)
}, [isVisible]) }, [isLost])
if (!isVisible) return null if (!isReconnecting && !isLost) return null
// ── Grace-period spinner ───────────────────────────────────────────────────
if (isReconnecting) {
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 99999,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}>
<div style={{
background: '#1e293b',
border: '2px solid #334155',
borderRadius: 20,
padding: '32px 28px',
maxWidth: 340, width: '100%',
textAlign: 'center',
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
}}>
{/* Spinning ring */}
<div style={{
width: 52, height: 52, margin: '0 auto 20px',
border: '4px solid #334155',
borderTopColor: 'var(--accent, #f97316)',
borderRadius: '50%',
animation: 'gate-spin 0.8s linear infinite',
}} />
<p style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9', marginBottom: 8 }}>
Επανασύνδεση
</p>
<p style={{ fontSize: 13, color: '#64748b', lineHeight: 1.6 }}>
Προσπαθώ να φτάσω στον server.
</p>
</div>
</div>
)
}
// ── Full "lost" modal ──────────────────────────────────────────────────────
return ( return (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 99999, position: 'fixed', inset: 0, zIndex: 99999,
@@ -58,33 +93,23 @@ export default function ConnectionLostModal() {
}}> }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div> <div style={{ fontSize: 48, marginBottom: 16 }}></div>
<p style={{ <p style={{ fontSize: 20, fontWeight: 700, color: '#f1f5f9', marginBottom: 10 }}>
fontSize: 20, fontWeight: 700, color: '#f1f5f9',
marginBottom: 10,
}}>
Χάθηκε η σύνδεση με τον Manager Χάθηκε η σύνδεση με τον Manager
</p> </p>
<p style={{ <p style={{ fontSize: 14, color: '#94a3b8', lineHeight: 1.6, marginBottom: 28 }}>
fontSize: 14, color: '#94a3b8', lineHeight: 1.6,
marginBottom: 28,
}}>
Δεν μπορώ να φτάσω στον server.{'\n'} Δεν μπορώ να φτάσω στον server.{'\n'}
Περίμενε ή άνοιξε <strong style={{ color: '#fbbf24' }}>ΕΚΤΑΚΤΗ ΛΕΙΤΟΥΡΓΙΑ</strong>{'\n'} Περίμενε ή άνοιξε <strong style={{ color: '#fbbf24' }}>ΕΚΤΑΚΤΗ ΛΕΙΤΟΥΡΓΙΑ</strong>{'\n'}
για να συνεχίσεις με τοπικά δεδομένα. για να συνεχίσεις με τοπικά δεδομένα.
</p> </p>
<div style={{ <div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
display: 'flex', gap: 12, justifyContent: 'center',
}}>
<button <button
onClick={enterEmergency} onClick={enterEmergency}
style={{ style={{
flex: 1, flex: 1, height: 48, borderRadius: 12, border: 'none',
height: 48, borderRadius: 12, border: 'none',
background: '#dc2626', color: '#fff', background: '#dc2626', color: '#fff',
fontSize: 15, fontWeight: 700, fontSize: 15, fontWeight: 700, cursor: 'pointer',
cursor: 'pointer',
}} }}
> >
EMERGENCY MODE EMERGENCY MODE

View File

@@ -281,7 +281,6 @@ function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) { function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
const isFree = !order const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0 const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
return ( return (
<div style={{ <div style={{
@@ -302,8 +301,9 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
<div style={{ marginTop: 5 }}> <div style={{ marginTop: 5 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small /> <StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
</div> </div>
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}> {/* Always reserve amount height — invisible when free */}
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />} <div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28, visibility: isFree ? 'hidden' : 'visible' }}>
<Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />
</div> </div>
</div> </div>
@@ -324,7 +324,6 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) { function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
const isFree = !order const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0 const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
return ( return (
<div style={{ <div style={{
@@ -345,9 +344,9 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
{/* separator dot */} {/* separator dot */}
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span> <span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
{/* amount */} {/* amount — always reserve space, invisible when free */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', visibility: isFree ? 'hidden' : 'visible' }}>
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />} <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />
</div> </div>
{/* flags up to 3 + +N */} {/* flags up to 3 + +N */}
@@ -365,7 +364,6 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) { function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
const isFree = !order const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0 const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
const showWaiters = !isFree && waiterObjects.length > 0 const showWaiters = !isFree && waiterObjects.length > 0
return ( return (
@@ -404,12 +402,10 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} /> <StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
</div> </div>
{/* right: amount — top-aligned */} {/* right: amount — always reserve space, invisible when free */}
{showAmount && ( <div style={{ flexShrink: 0, visibility: isFree ? 'hidden' : 'visible' }}>
<div style={{ flexShrink: 0 }}> <Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} /> </div>
</div>
)}
</div> </div>
{/* flag chips row — right-aligned */} {/* flag chips row — right-aligned */}
@@ -479,8 +475,8 @@ function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey
)} )}
</div> </div>
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10, visibility: isFree ? 'hidden' : 'visible' }}>
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />} <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>

View File

@@ -1,10 +1,12 @@
import { createContext, useContext, useCallback, useEffect, useRef } from 'react' import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import useConnectionStore from '../store/connectionStore' import useConnectionStore from '../store/connectionStore'
import { useSSE } from '../hooks/useSSE' import { useSSE } from '../hooks/useSSE'
import db from '../db/posdb' import db from '../db/posdb'
import client from '../api/client' import client from '../api/client'
import { flushOfflinePayments } from '../services/offlinePayments' import { flushOfflinePayments } from '../services/offlinePayments'
import { invalidateProductCache } from '../hooks/useProductCache'
const SSEContext = createContext(null) const SSEContext = createContext(null)
@@ -17,6 +19,7 @@ const HEARTBEAT_INTERVAL = 30_000
export function SSEProvider({ children }) { export function SSEProvider({ children }) {
const { token } = useAuthStore() const { token } = useAuthStore()
const { setLost, setOnline } = useConnectionStore() const { setLost, setOnline } = useConnectionStore()
const queryClient = useQueryClient()
const sseAlive = useRef(false) const sseAlive = useRef(false)
const heartbeatRef = useRef(null) const heartbeatRef = useRef(null)
@@ -97,10 +100,14 @@ export function SSEProvider({ children }) {
await snapshotTables() await snapshotTables()
break break
} }
case 'products_changed': {
invalidateProductCache(queryClient)
break
}
default: default:
break break
} }
}, [snapshotTables]) }, [snapshotTables, queryClient])
// ── SSE connection lifecycle ───────────────────────────────────────────────── // ── SSE connection lifecycle ─────────────────────────────────────────────────
@@ -175,6 +182,32 @@ export function SSEProvider({ children }) {
return () => window.removeEventListener('backend-offline', onBackendOffline) return () => window.removeEventListener('backend-offline', onBackendOffline)
}, []) }, [])
// ── Wake-up handshake — fires when tab/app returns from background ────────────
useEffect(() => {
if (!token) return
async function onVisible() {
if (document.visibilityState !== 'visible') return
try {
await client.get('/api/system/health')
const currentStatus = useConnectionStore.getState().status
if (currentStatus === 'lost' || currentStatus === 'emergency' || currentStatus === 'reconnecting') {
setOnlineRef.current()
reconnect()
await fullRefresh()
} else if (!sseAlive.current) {
// SSE dropped silently while sleeping — re-establish quietly
reconnect()
await fullRefresh()
}
} catch {
if (!sseAlive.current) setLostRef.current()
}
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [token, reconnect, fullRefresh])
// ── Initial snapshot on login ───────────────────────────────────────────────── // ── Initial snapshot on login ─────────────────────────────────────────────────
useEffect(() => { useEffect(() => {

View File

@@ -7,9 +7,17 @@ import Dexie from 'dexie'
const db = new Dexie('pos_snapshot') const db = new Dexie('pos_snapshot')
db.version(1).stores({ db.version(1).stores({
tables: 'id, group_id, is_active', // TableOut snapshots tables: 'id, group_id, is_active',
orders: 'id, table_id, status', // ActiveOrderSlim + OrderOut snapshots orders: 'id, table_id, status',
offline_payments: '++localId, uuid, synced', // queued emergency payments offline_payments: '++localId, uuid, synced',
})
db.version(2).stores({
tables: 'id, group_id, is_active',
orders: 'id, table_id, status',
offline_payments: '++localId, uuid, synced',
products: 'id, category_id, is_available',
categories: 'id, parent_id',
}) })
export default db export default db

View File

@@ -0,0 +1,69 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import client from '../api/client'
import db from '../db/posdb'
export const PRODUCTS_KEY = ['products']
export const CATEGORIES_KEY = ['categories']
async function fetchAndCacheProducts() {
const [prodRes, catRes] = await Promise.all([
client.get('/api/products/'),
client.get('/api/products/categories'),
])
const products = prodRes.data
const categories = catRes.data
// Write to IndexedDB in the background — don't await so UI isn't blocked
db.products.bulkPut(products).catch(() => {})
db.categories.bulkPut(categories).catch(() => {})
return { products, categories }
}
async function loadFromCache() {
const [products, categories] = await Promise.all([
db.products.toArray(),
db.categories.toArray(),
])
if (products.length === 0 && categories.length === 0) return null
return { products, categories }
}
export function useProductCache() {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: PRODUCTS_KEY,
queryFn: fetchAndCacheProducts,
// Serve stale data instantly — products don't change every second
staleTime: 5 * 60 * 1000, // 5 min before background re-fetch
gcTime: 60 * 60 * 1000, // keep in memory for 1 hour
placeholderData: undefined,
})
// On mount, if the query has no data yet, seed it from IndexedDB immediately
// so the UI renders without waiting for the network round-trip
useEffect(() => {
const cached = queryClient.getQueryData(PRODUCTS_KEY)
if (cached) return
loadFromCache().then(idbData => {
if (idbData) {
// Set as placeholder — React Query will still fetch in background
queryClient.setQueryData(PRODUCTS_KEY, idbData)
}
})
}, [queryClient])
return {
products: query.data?.products ?? [],
categories: query.data?.categories ?? [],
isLoading: query.isLoading && !query.data,
}
}
// Call this from SSEContext when products_changed arrives
export function invalidateProductCache(queryClient) {
queryClient.invalidateQueries({ queryKey: PRODUCTS_KEY })
}

View File

@@ -667,7 +667,14 @@ html, body {
.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; } .user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
.user-menu-item--disabled:hover { background: transparent; } .user-menu-item--disabled:hover { background: transparent; }
.user-menu-item--danger { color: var(--danger); } .user-menu-item--danger { color: var(--danger); }
.user-menu-item__icon { font-size: 17px; flex-shrink: 0; } .user-menu-item__icon {
font-size: 17px;
flex-shrink: 0;
width: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.user-menu-divider { .user-menu-divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);

View File

@@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import ProductPicker from '../components/ProductPicker' import ProductPicker from '../components/ProductPicker'
import OrderDrawer from '../components/OrderDrawer' import OrderDrawer from '../components/OrderDrawer'
import client from '../api/client' import client from '../api/client'
import { useProductCache } from '../hooks/useProductCache'
export default function AddItemsPage() { export default function AddItemsPage() {
const { tableId } = useParams() const { tableId } = useParams()
@@ -10,8 +11,8 @@ export default function AddItemsPage() {
const isNewTable = searchParams.get('new') === '1' const isNewTable = searchParams.get('new') === '1'
const navigate = useNavigate() const navigate = useNavigate()
const [categories, setCategories] = useState([]) const { products, categories } = useProductCache()
const [products, setProducts] = useState([])
const [cart, setCart] = useState([]) const [cart, setCart] = useState([])
const [orderId, setOrderId] = useState(null) const [orderId, setOrderId] = useState(null)
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
@@ -24,16 +25,9 @@ export default function AddItemsPage() {
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const [catRes, prodRes, statusRes] = await Promise.all([ const statusRes = await client.get(`/api/tables/${tableId}/status`)
client.get('/api/products/categories'),
client.get('/api/products/'),
client.get(`/api/tables/${tableId}/status`),
])
setCategories(catRes.data)
setProducts(prodRes.data)
setOrderId(statusRes.data.active_order_id) setOrderId(statusRes.data.active_order_id)
// Pre-populate cart from "order again" if present // Pre-populate cart from "order again" if present

View File

@@ -762,9 +762,10 @@ function ZoneTab({ label, color, active, onClick }) {
onClick={onClick} onClick={onClick}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 12px', borderRadius: 20, border: 'none', padding: '8px 16px', borderRadius: 20,
border: '2px solid transparent',
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
fontWeight: 600, fontSize: 13, fontWeight: 600, fontSize: 14,
background: active ? 'var(--accent)' : 'var(--bg3)', background: active ? 'var(--accent)' : 'var(--bg3)',
color: active ? 'var(--accent-fg)' : 'var(--muted)', color: active ? 'var(--accent-fg)' : 'var(--muted)',
transition: 'background 0.12s, color 0.12s', transition: 'background 0.12s, color 0.12s',

View File

@@ -4,26 +4,50 @@ import { create } from 'zustand'
* Tracks the live connection state and emergency mode flag. * Tracks the live connection state and emergency mode flag.
* *
* States: * States:
* 'online' — server reachable, SSE connected, normal operation * 'online' — server reachable, SSE connected, normal operation
* 'lost' — server unreachable, modal shown (Wait / Emergency) * 'reconnecting' — connection blip detected; 5-second grace before showing full modal
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot * 'lost' — grace period expired, modal shown (Wait / Emergency)
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
*/ */
const GRACE_MS = 5_000
const useConnectionStore = create((set, get) => ({ const useConnectionStore = create((set, get) => ({
status: 'online', // 'online' | 'lost' | 'emergency' status: 'online', // 'online' | 'reconnecting' | 'lost' | 'emergency'
lostAt: null, // Date when connection was lost lostAt: null,
_graceTimer: null,
setLost: () => { setLost: () => {
if (get().status === 'online') { const { status, _graceTimer } = get()
set({ status: 'lost', lostAt: new Date() }) // Already lost or in emergency — no-op
} if (status === 'lost' || status === 'emergency') return
// Already in grace period — don't restart the timer
if (status === 'reconnecting') return
// Start grace period
const timer = setTimeout(() => {
// Only escalate if we're still in reconnecting (not recovered in the meantime)
if (get().status === 'reconnecting') {
set({ status: 'lost', _graceTimer: null })
}
}, GRACE_MS)
set({ status: 'reconnecting', lostAt: new Date(), _graceTimer: timer })
}, },
setOnline: () => set({ status: 'online', lostAt: null }), setOnline: () => {
const { _graceTimer } = get()
if (_graceTimer) clearTimeout(_graceTimer)
set({ status: 'online', lostAt: null, _graceTimer: null })
},
enterEmergency: () => set({ status: 'emergency' }), enterEmergency: () => {
const { _graceTimer } = get()
if (_graceTimer) clearTimeout(_graceTimer)
set({ status: 'emergency', _graceTimer: null })
},
// Called when server comes back while in emergency mode — triggers sync then go online exitEmergency: () => set({ status: 'online', lostAt: null, _graceTimer: null }),
exitEmergency: () => set({ status: 'online', lostAt: null }),
isOnline: () => get().status === 'online', isOnline: () => get().status === 'online',
isLost: () => get().status === 'lost', isLost: () => get().status === 'lost',

View File

@@ -13,8 +13,8 @@ export default defineConfig({
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
manifest: { manifest: {
name: 'TableServe', name: 'Xenia',
short_name: 'TableServe', short_name: 'Xenia',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',
background_color: '#0f172a', background_color: '#0f172a',