Compare commits
3 Commits
8ba8c95ecd
...
5de89a722c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de89a722c | |||
| aa92623802 | |||
| 0d21b7f20b |
14
.env.example
14
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
115
install.sh
115
install.sh
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
oid = item.order_id
|
if all_waiter_ids:
|
||||||
if oid not in orders_seen:
|
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
|
||||||
o = item.order
|
waiters_map[w.id] = w.full_name or w.username
|
||||||
tid = o.table_id if o else None
|
|
||||||
orders_seen[oid] = {
|
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,
|
"order_id": oid,
|
||||||
"table_id": tid,
|
"table_id": tid,
|
||||||
"table_name": tables_map.get(tid) if tid else None,
|
"table_name": tables_map.get(tid) if tid else None,
|
||||||
"opened_at": _dt(o.opened_at) if o 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": [],
|
"items": [],
|
||||||
}
|
}
|
||||||
orders_seen[oid]["items"].append({
|
|
||||||
|
# Attach items to their orders
|
||||||
|
for item in all_items:
|
||||||
|
oid = item.order_id
|
||||||
|
if oid not in orders_out:
|
||||||
|
continue
|
||||||
|
orders_out[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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,26 +331,37 @@ 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'
|
||||||
|
const isClosedItem = item.status === 'closed'
|
||||||
|
const badgeStatus = isClosedItem ? 'closed_item' : item.status
|
||||||
|
return (
|
||||||
|
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${isCancelled ? 'opacity-40 line-through' : ''} ${isClosedItem ? 'bg-amber-50/50' : ''}`}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||||
{item.paid_by && (
|
{item.paid_by && (
|
||||||
<p className="text-xs text-green-600 mt-0.5">
|
<p className="text-xs text-green-600 mt-0.5">
|
||||||
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
Πληρώθηκε: {item.paid_by_name ?? waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||||
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
|
{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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isClosedItem && (
|
||||||
|
<p className="text-xs text-amber-700 mt-0.5 font-medium">Απλήρωτο — κλείστηκε από manager</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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>
|
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||||
{isOpen && !readOnly && item.status === 'active' && (
|
{isOpen && !readOnly && item.status === 'active' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => payItems.mutate([item.id])}
|
onClick={() => requestPay([item.id])}
|
||||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||||
>
|
>
|
||||||
Πληρωμή
|
Πληρωμή
|
||||||
@@ -313,14 +376,15 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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={
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
286
manager_dashboard/src/pages/reports/shared/OrderDetailModal.jsx
Normal file
286
manager_dashboard/src/pages/reports/shared/OrderDetailModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
Normal file
263
manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<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' : ''}`} />
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const LABELS = {
|
|||||||
partially_paid: 'Μερική πληρωμή',
|
partially_paid: 'Μερική πληρωμή',
|
||||||
paid: 'Πληρώθηκε',
|
paid: 'Πληρώθηκε',
|
||||||
closed: 'Κλειστό',
|
closed: 'Κλειστό',
|
||||||
|
closed_item: 'Κλειστό (απλήρωτο)',
|
||||||
'force-closed': 'Αναγκαστικό κλείσιμο',
|
'force-closed': 'Αναγκαστικό κλείσιμο',
|
||||||
cancelled: 'Ακυρώθηκε',
|
cancelled: 'Ακυρώθηκε',
|
||||||
completed: 'Ολοκληρώθηκε',
|
completed: 'Ολοκληρώθηκε',
|
||||||
|
|||||||
101
manager_dashboard/src/ui/DeleteConfirmModal.jsx
Normal file
101
manager_dashboard/src/ui/DeleteConfirmModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
manager_dashboard/src/ui/PaymentMethodModal.jsx
Normal file
54
manager_dashboard/src/ui/PaymentMethodModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
27
waiter_pwa/package-lock.json
generated
27
waiter_pwa/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
69
waiter_pwa/src/hooks/useProductCache.js
Normal file
69
waiter_pwa/src/hooks/useProductCache.js
Normal 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 })
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -5,25 +5,49 @@ import { create } from 'zustand'
|
|||||||
*
|
*
|
||||||
* 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
|
||||||
|
* 'lost' — grace period expired, modal shown (Wait / Emergency)
|
||||||
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
|
* '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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user