From b94585084a48919c7a1b056f2dac838cbf9e0a53 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sun, 12 Jul 2026 12:15:16 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20KDS=20scaffold=20=E2=80=94=20Phase=20?= =?UTF-8?q?2=20kitchen=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI backend (Python 3.11, httpx for SignalR/GraphQL — no MSSQL ODBC), shares kitchen_db directly. React/TS/Vite fullscreen board frontend. Backend: auth.py (APP_SLUG=kds, SimpleNamespace), main.py (4 KDS migrations, SignalR start/stop), kds.py router, models (kds/settings/resos — read from kitchen_db), signalr_listener.py (backoff pre-existing), kds_graphql.py, database.py. Requirements stripped to ~9 packages; image ~400 MB lighter than kitchen (no MSSQL ODBC layer). Frontend: AuthGate (app=kds), single fullscreen route, dark board theme. KDS.tsx URL prefix patched (/api/kds/ → /kds/api/kds/), recipe images cross-app (/kitchen/api/recipes/). nginx: 5 blocks with SSE proxy headers on /kds/api/ block. Co-Authored-By: Claude Sonnet 4.6 --- _copy_from_archive.sh | 59 + backend/Dockerfile | 19 + backend/api/__init__.py | 0 backend/api/kds.py | 1514 +++++++++++++++ backend/auth.py | 63 + backend/database.py | 38 + backend/main.py | 66 + backend/migrations/__init__.py | 0 .../migrations/add_kds_bookings_refresh.py | 38 + backend/migrations/add_kds_course_flow.py | 54 + backend/migrations/add_kds_order_tracking.py | 39 + backend/migrations/add_kds_tables.py | 124 ++ backend/models/__init__.py | 0 backend/models/kds.py | 88 + backend/models/resos.py | 145 ++ backend/models/settings.py | 238 +++ backend/models/user.py | 14 + backend/requirements.txt | 19 + backend/services/__init__.py | 0 backend/services/kds_graphql.py | 382 ++++ backend/services/signalr_listener.py | 296 +++ docker-compose.yml | 32 + frontend/Dockerfile | 15 + frontend/index.html | 13 + frontend/nginx.conf | 41 + frontend/package.json | 25 + frontend/src/App.tsx | 18 + frontend/src/components/AuthGate.tsx | 67 + frontend/src/index.css | 89 + frontend/src/main.tsx | 20 + frontend/src/pages/KDS.tsx | 1629 +++++++++++++++++ frontend/src/pages/KDSApp.tsx | 13 + frontend/src/types.ts | 11 + frontend/tsconfig.json | 19 + frontend/vite.config.ts | 7 + 35 files changed, 5195 insertions(+) create mode 100644 _copy_from_archive.sh create mode 100644 backend/Dockerfile create mode 100644 backend/api/__init__.py create mode 100644 backend/api/kds.py create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/migrations/__init__.py create mode 100644 backend/migrations/add_kds_bookings_refresh.py create mode 100644 backend/migrations/add_kds_course_flow.py create mode 100644 backend/migrations/add_kds_order_tracking.py create mode 100644 backend/migrations/add_kds_tables.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/kds.py create mode 100644 backend/models/resos.py create mode 100644 backend/models/settings.py create mode 100644 backend/models/user.py create mode 100644 backend/requirements.txt create mode 100644 backend/services/__init__.py create mode 100644 backend/services/kds_graphql.py create mode 100644 backend/services/signalr_listener.py create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/AuthGate.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/KDS.tsx create mode 100644 frontend/src/pages/KDSApp.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/_copy_from_archive.sh b/_copy_from_archive.sh new file mode 100644 index 0000000..1efa2ee --- /dev/null +++ b/_copy_from_archive.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Phase 2 setup — copy KDS-specific archive files into kds/ +# Run once from the repo root: bash kds/_copy_from_archive.sh +set -euo pipefail + +ARCHIVE=/home/jtr/laptop-archive/kitchen-invoice-flash-docker +DEST=/home/jtr/dev/HNF-PROXMOX/kds + +echo "==> Copying KDS backend from archive..." + +# KDS API router +cp "$ARCHIVE/backend/api/kds.py" "$DEST/backend/api/" + +# KDS models +cp "$ARCHIVE/backend/models/kds.py" "$DEST/backend/models/" +cp "$ARCHIVE/backend/models/settings.py" "$DEST/backend/models/" +cp "$ARCHIVE/backend/models/resos.py" "$DEST/backend/models/" + +# KDS migrations (4 files — add kds_tables, course_flow, order_tracking, bookings_refresh) +cp "$ARCHIVE/backend/migrations/add_kds_tables.py" "$DEST/backend/migrations/" +cp "$ARCHIVE/backend/migrations/add_kds_course_flow.py" "$DEST/backend/migrations/" +cp "$ARCHIVE/backend/migrations/add_kds_order_tracking.py" "$DEST/backend/migrations/" +cp "$ARCHIVE/backend/migrations/add_kds_bookings_refresh.py" "$DEST/backend/migrations/" + +# SignalR listener (already has exponential-backoff reconnect — log A12 satisfied) +cp "$ARCHIVE/backend/services/signalr_listener.py" "$DEST/backend/services/" + +# SambaPOS GraphQL client (httpx-based — no MSSQL/ODBC needed in KDS) +cp "$ARCHIVE/backend/services/kds_graphql.py" "$DEST/backend/services/" + +# database.py — same asyncpg/SQLAlchemy setup as kitchen +cp "$ARCHIVE/backend/database.py" "$DEST/backend/" + +echo "==> Patching auth import in api/kds.py..." +# Replace old local JWT import with new central-auth import +sed -i 's/from auth\.jwt import get_current_user/from auth import get_current_user/g' \ + "$DEST/backend/api/kds.py" +# Remove old User model import from kds.py — now uses auth stub (auth.py returns SimpleNamespace) +sed -i 's/^from models\.user import User$/from models.user import User # type stub — actual user is SimpleNamespace from auth.py/g' \ + "$DEST/backend/api/kds.py" + +echo "==> Copying KDS frontend page..." +# KDS.tsx: full board UI — patch URL prefix from /api/kds/ to /kds/api/kds/ +cp "$ARCHIVE/frontend/src/pages/KDS.tsx" "$DEST/frontend/src/pages/" +sed -i "s|fetch('/api/kds/|fetch('/kds/api/kds/|g" "$DEST/frontend/src/pages/KDS.tsx" +sed -i "s|new EventSource('/api/kds/|new EventSource('/kds/api/kds/|g" \ + "$DEST/frontend/src/pages/KDS.tsx" +# Recipe images cross-app: KDS fetches recipe images from kitchen backend +sed -i "s|src={\`/api/recipes/|src={\`/kitchen/api/recipes/|g" \ + "$DEST/frontend/src/pages/KDS.tsx" +# Also handle token-based Authorization headers in KDS fetch calls: +# KDS.tsx uses { headers: { Authorization: ... } } from useAuth().token. +# token = '__session__' (truthy sentinel) — header is sent but ignored by backend; +# cookie auth (hnf_session) is used instead. No change needed: same-origin fetches +# send cookies automatically. B5b tracking not required for KDS (only ~8 endpoints). + +echo "" +echo "Done. Verify build: cd kds && docker compose build && cd frontend && npm install && npm run build" +echo "Deploy order: commit → push to Forgejo → then pct exec 125 ..." diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1e1603f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/kds.py b/backend/api/kds.py new file mode 100644 index 0000000..3c4dea1 --- /dev/null +++ b/backend/api/kds.py @@ -0,0 +1,1514 @@ +""" +KDS (Kitchen Display System) API Endpoints + +Provides endpoints for: +- Fetching open tickets with kitchen orders +- Course flow: PENDING → AWAY → SENT +- KDS settings management + +Course Flow: +- When a ticket arrives, the first course is auto-set to "away" (called away from kitchen) +- Staff presses SENT when food is delivered to the table +- Staff presses AWAY on next course when clearing previous course / calling away next +- Strictly sequential: must SENT current before AWAY on next +""" + +import asyncio +import logging +from datetime import datetime, timezone +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from sqlalchemy.orm.attributes import flag_modified +from pydantic import BaseModel + +from database import get_db +from auth import get_current_user +from models.user import User +from models.settings import KitchenSettings +from models.kds import KDSTicket, KDSCourseBump +from services.kds_graphql import ( + SambaPOSGraphQLClient, + transform_ticket_for_kds, + parse_kitchen_course +) +from services.signalr_listener import kds_event_bus + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============================================================================= +# Pydantic Schemas +# ============================================================================= + +class KDSSettingsResponse(BaseModel): + kds_enabled: bool = False + kds_graphql_url: Optional[str] = None + kds_graphql_username: Optional[str] = None + kds_graphql_password_set: bool = False + kds_graphql_client_id: Optional[str] = None + kds_poll_interval_seconds: int = 6000 + kds_timer_green_seconds: int = 300 + kds_timer_amber_seconds: int = 600 + kds_timer_red_seconds: int = 900 + kds_away_timer_green_seconds: int = 600 + kds_away_timer_amber_seconds: int = 900 + kds_away_timer_red_seconds: int = 1200 + kds_course_order: list = ["Starters", "Mains", "Desserts"] + kds_show_completed_for_seconds: int = 30 + kds_bookings_refresh_seconds: int = 60 + + class Config: + from_attributes = True + + +class KDSSettingsUpdate(BaseModel): + kds_enabled: Optional[bool] = None + kds_graphql_url: Optional[str] = None + kds_graphql_username: Optional[str] = None + kds_graphql_password: Optional[str] = None + kds_graphql_client_id: Optional[str] = None + kds_poll_interval_seconds: Optional[int] = None + kds_timer_green_seconds: Optional[int] = None + kds_timer_amber_seconds: Optional[int] = None + kds_timer_red_seconds: Optional[int] = None + kds_away_timer_green_seconds: Optional[int] = None + kds_away_timer_amber_seconds: Optional[int] = None + kds_away_timer_red_seconds: Optional[int] = None + kds_course_order: Optional[list] = None + kds_show_completed_for_seconds: Optional[int] = None + kds_bookings_refresh_seconds: Optional[int] = None + + +class KDSOrderTagResponse(BaseModel): + tag: str + tagName: str + quantity: Optional[float] = 1 + + +class KDSOrderResponse(BaseModel): + id: int + uid: Optional[str] = None + name: str + portion: Optional[str] = None + quantity: float + price: Optional[float] = None + kitchen_course: Optional[str] = None + status: str + kitchen_print: Optional[str] = None + is_voided: bool = False + voided_at: Optional[str] = None # ISO timestamp when voided + is_sent: bool = False # This individual order has been sent to table + is_addition: bool = False # Order was added after ticket first appeared in KDS + tags: list[KDSOrderTagResponse] = [] + + +class KDSTicketResponse(BaseModel): + id: int + sambapos_ticket_id: int + ticket_number: str + table_name: Optional[str] = None + covers: Optional[int] = None + received_at: datetime + time_elapsed_seconds: int + orders: list[KDSOrderResponse] + orders_by_course: dict + course_states: dict + is_bumped: bool = False + + +class CourseActionRequest(BaseModel): + ticket_id: int # Local KDS ticket ID + course_name: str + + +class CourseActionResponse(BaseModel): + success: bool + message: str + ticket_id: int + course_name: str + action: str # "away" or "sent" + timestamp: datetime + + +# Keep old schema for backward compat +class CourseBumpRequest(BaseModel): + ticket_id: int + course_name: str + + +class CourseBumpResponse(BaseModel): + success: bool + message: str + ticket_id: int + course_name: str + bumped_at: datetime + + +class KDSBookingItem(BaseModel): + booking_time: str + people: int + status: str + table_name: Optional[str] = None + seating_area: Optional[str] = None + is_hotel_guest: Optional[bool] = None + is_dbb: Optional[bool] = None + is_package: Optional[bool] = None + is_flagged: bool = False + flag_reasons: Optional[str] = None + allergies: Optional[str] = None + kds_stage: Optional[str] = None + + +class KDSBookingsResponse(BaseModel): + period_name: Optional[str] = None + total_bookings: int = 0 + total_covers: int = 0 + flag_icon_mapping: Optional[dict] = None + bookings: list[KDSBookingItem] = [] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +async def get_kds_settings(db: AsyncSession, kitchen_id: int) -> Optional[KitchenSettings]: + """Get KDS settings for a kitchen.""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id) + ) + return result.scalar_one_or_none() + + +def normalize_course_config(course_order: list, settings: KitchenSettings) -> list[dict]: + """Convert old string-based course_order to new object format with per-course timers. + + Old format: ["Starters", "Mains", "Desserts"] + New format: [{"name": "Starters", "prep_green": 300, ...}, ...] + + Falls back to global timer thresholds for old string entries. + """ + if not course_order: + return [] + result = [] + for entry in course_order: + if isinstance(entry, str): + result.append({ + "name": entry, + "prep_green": settings.kds_timer_green_seconds or 300, + "prep_amber": settings.kds_timer_amber_seconds or 600, + "prep_red": settings.kds_timer_red_seconds or 900, + "away_green": settings.kds_away_timer_green_seconds or 600, + "away_amber": settings.kds_away_timer_amber_seconds or 900, + "away_red": settings.kds_away_timer_red_seconds or 1200, + }) + elif isinstance(entry, dict) and "name" in entry: + result.append(entry) + return result + + +def extract_course_names(course_order: list) -> list[str]: + """Extract course names from course config (handles both old string and new object format).""" + names = [] + for entry in (course_order or []): + if isinstance(entry, str): + names.append(entry) + elif isinstance(entry, dict) and "name" in entry: + names.append(entry["name"]) + return names + + +def normalize_table_number(table_name: Optional[str]) -> Optional[int]: + """Extract numeric table number from a table name string. + + Strips all non-digit characters and converts to int. + Handles various formats: "Table 1" -> 1, "T01" -> 1, "12" -> 12. + Returns None if no digits found. + """ + if not table_name: + return None + import re + digits = re.sub(r'\D', '', table_name) + if not digits: + return None + return int(digits) + + +def derive_kds_stage(ticket: "KDSTicket", course_order: list) -> Optional[str]: + """Derive current KDS stage label from a ticket's course states. + + Returns a stage string like "ORDERED", "STARTERS SENT", "MAINS AWAY", + "COMPLETE", or None if no stage can be determined. + + Handles tickets that don't have all courses by skipping missing ones. + """ + if ticket.is_bumped: + return "COMPLETE" + + course_states = ticket.course_states or {} + if not course_states: + return "ORDERED" + + ordered_courses = get_ordered_courses_for_ticket( + ticket.orders_data or [], course_order + ) + if not ordered_courses: + return "ORDERED" + + # Walk through courses to find the furthest progressed state + latest_stage = "ORDERED" + for course_name in ordered_courses: + state = course_states.get(course_name, {}) + status = state.get("status", "pending") + course_label = course_name.upper() + + if status == "away": + latest_stage = f"{course_label} AWAY" + elif status in ("sent", "cleared"): + latest_stage = f"{course_label} SENT" + + return latest_stage + + +def get_ordered_courses_for_ticket(orders_data: list, course_order: list) -> list[str]: + """Get the ordered list of courses present in a ticket.""" + ticket_courses = [] + seen = set() + for order in orders_data: + course = order.get("kitchen_course", "Uncategorized") + if course not in seen: + seen.add(course) + ticket_courses.append(course) + + # Extract names from course config (handles both string and object format) + config_names = extract_course_names(course_order) + + # Sort by configured course order, then append any not in config + ordered = [c for c in config_names if c in seen] + ordered.extend([c for c in ticket_courses if c not in set(ordered)]) + return ordered + + +def initialize_course_states(ordered_courses: list, received_at: datetime) -> dict: + """Initialize course states for a new ticket. + + First course is auto-set to 'away' (called away when ticket created). + All others start as 'pending'. + """ + states = {} + for i, course in enumerate(ordered_courses): + if i == 0: + states[course] = { + "status": "away", + "called_away_at": received_at.isoformat(), + "sent_at": None, + "sent_by": None, + "sent_order_ids": [], + } + else: + states[course] = { + "status": "pending", + "called_away_at": None, + "sent_at": None, + "sent_by": None, + "sent_order_ids": [], + } + return states + + +def migrate_old_course_states(course_states: dict) -> dict: + """Convert old format course_states to new format. + + Old: {"Starters": {"bumped": true, "bumped_at": "...", "bumped_by": "..."}} + New: {"Starters": {"status": "sent", "called_away_at": null, "sent_at": "...", "sent_by": "..."}} + """ + migrated = {} + for course, state in course_states.items(): + if isinstance(state, dict) and "status" in state: + # Already new format + migrated[course] = state + elif isinstance(state, dict) and "bumped" in state: + # Old format + if state.get("bumped"): + migrated[course] = { + "status": "sent", + "called_away_at": None, + "sent_at": state.get("bumped_at"), + "sent_by": state.get("bumped_by"), + } + else: + migrated[course] = { + "status": "pending", + "called_away_at": None, + "sent_at": None, + "sent_by": None, + } + else: + migrated[course] = { + "status": "pending", + "called_away_at": None, + "sent_at": None, + "sent_by": None, + } + return migrated + + +async def get_or_create_kds_ticket( + db: AsyncSession, + kitchen_id: int, + sambapos_ticket: dict, + course_order: list +) -> KDSTicket: + """Get existing KDS ticket or create new one from SambaPOS data.""" + # Check if ticket already exists + result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.kitchen_id == kitchen_id, + KDSTicket.sambapos_ticket_id == sambapos_ticket["id"], + KDSTicket.is_active == True + ) + ) + ) + kds_ticket = result.scalar_one_or_none() + + if kds_ticket: + # Detect new orders BEFORE overwriting orders_data + old_order_ids = {o.get("id") for o in (kds_ticket.orders_data or [])} + incoming_orders = sambapos_ticket.get("orders", []) + new_order_ids = {o.get("id") for o in incoming_orders} + added_order_ids = new_order_ids - old_order_ids + + if added_order_ids: + logger.info(f"KDS: Ticket {sambapos_ticket.get('number')} — detected {len(added_order_ids)} new order(s): {added_order_ids}") + + # Update with latest data + kds_ticket.table_name = sambapos_ticket.get("table") + kds_ticket.covers = sambapos_ticket.get("covers") + kds_ticket.orders_data = incoming_orders + kds_ticket.last_sambapos_update = datetime.utcnow() + kds_ticket.updated_at = datetime.utcnow() + + # Un-bump completed tickets if new orders were added + if added_order_ids and kds_ticket.is_bumped: + kds_ticket.is_bumped = False + kds_ticket.bumped_at = None + logger.info(f"KDS: Ticket {kds_ticket.ticket_number} un-bumped — {len(added_order_ids)} new order(s) added") + + # Migrate old course_states format if needed + if kds_ticket.course_states: + first_state = next(iter(kds_ticket.course_states.values()), None) + if isinstance(first_state, dict) and "bumped" in first_state and "status" not in first_state: + kds_ticket.course_states = migrate_old_course_states(kds_ticket.course_states) + + # Ensure course_states covers all courses in ticket + ordered_courses = get_ordered_courses_for_ticket(incoming_orders, course_order) + # IMPORTANT: dict() creates a shallow copy so SQLAlchemy detects the change + # when we reassign kds_ticket.course_states later (same-object assignment + # is silently ignored by SQLAlchemy's JSONB dirty tracking) + current_states = dict(kds_ticket.course_states or {}) + for course in ordered_courses: + if course not in current_states: + current_states[course] = { + "status": "pending", + "called_away_at": None, + "sent_at": None, + "sent_by": None, + "sent_order_ids": [], + } + + # Backfill sent_order_ids for existing course states that don't have it + for course_name in current_states: + if "sent_order_ids" not in current_states[course_name]: + current_states[course_name]["sent_order_ids"] = [] + + # Reactivate cleared/sent courses that received new orders + if added_order_ids: + courses_with_new_orders = { + o.get("kitchen_course", "Uncategorized") + for o in incoming_orders if o.get("id") in added_order_ids + } + now_iso = datetime.utcnow().isoformat() + for course_name in courses_with_new_orders: + if course_name in current_states: + status = current_states[course_name].get("status") + if status in ("cleared", "sent"): + logger.info(f"KDS: Reopening course '{course_name}' on ticket {kds_ticket.ticket_number} — new orders added") + current_states[course_name] = { + "status": "away", + "called_away_at": now_iso, + "sent_at": None, + "sent_by": None, + "sent_order_ids": current_states[course_name].get("sent_order_ids", []), + } + + # If no course states exist at all, initialize (first course as away) + if not kds_ticket.course_states or len(kds_ticket.course_states) == 0: + kds_ticket.course_states = initialize_course_states( + ordered_courses, kds_ticket.received_at + ) + else: + kds_ticket.course_states = current_states + + # Ensure SQLAlchemy persists JSONB changes (belt-and-braces) + flag_modified(kds_ticket, "course_states") + flag_modified(kds_ticket, "orders_data") + else: + # Create new ticket + now = datetime.utcnow() + + # Use SambaPOS ticket creation time as the timer start + # (when the first order was submitted), falling back to now + submitted_at = now + submitted_at_str = sambapos_ticket.get("submitted_at") + if submitted_at_str: + try: + parsed = datetime.fromisoformat(submitted_at_str.replace("Z", "+00:00")) + # Convert to naive UTC for consistency with DB + submitted_at = parsed.replace(tzinfo=None) + except (ValueError, AttributeError): + logger.warning(f"Failed to parse submitted_at: {submitted_at_str}, using current time") + + orders = sambapos_ticket.get("orders", []) + ordered_courses = get_ordered_courses_for_ticket(orders, course_order) + + kds_ticket = KDSTicket( + kitchen_id=kitchen_id, + sambapos_ticket_id=sambapos_ticket["id"], + sambapos_ticket_uid=sambapos_ticket.get("uid"), + ticket_number=str(sambapos_ticket.get("number", "")), + table_name=sambapos_ticket.get("table"), + covers=sambapos_ticket.get("covers"), + total_amount=sambapos_ticket.get("total_amount"), + orders_data=orders, + initial_order_ids=[o.get("id") for o in orders], + course_states=initialize_course_states(ordered_courses, submitted_at), + received_at=submitted_at, + last_sambapos_update=now + ) + db.add(kds_ticket) + + await db.commit() + await db.refresh(kds_ticket) + return kds_ticket + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.get("/settings", response_model=KDSSettingsResponse) +async def get_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get KDS settings for the current kitchen.""" + settings = await get_kds_settings(db, current_user.kitchen_id) + + if not settings: + return KDSSettingsResponse() + + # Normalize course order: convert old string format to new object format + raw_course_order = settings.kds_course_order or ["Starters", "Mains", "Desserts"] + normalized_courses = normalize_course_config(raw_course_order, settings) + + return KDSSettingsResponse( + kds_enabled=settings.kds_enabled or False, + kds_graphql_url=settings.kds_graphql_url, + kds_graphql_username=settings.kds_graphql_username, + kds_graphql_password_set=bool(settings.kds_graphql_password), + kds_graphql_client_id=settings.kds_graphql_client_id, + kds_poll_interval_seconds=settings.kds_poll_interval_seconds or 6000, + kds_timer_green_seconds=settings.kds_timer_green_seconds or 300, + kds_timer_amber_seconds=settings.kds_timer_amber_seconds or 600, + kds_timer_red_seconds=settings.kds_timer_red_seconds or 900, + kds_away_timer_green_seconds=settings.kds_away_timer_green_seconds or 600, + kds_away_timer_amber_seconds=settings.kds_away_timer_amber_seconds or 900, + kds_away_timer_red_seconds=settings.kds_away_timer_red_seconds or 1200, + kds_course_order=normalized_courses, + kds_show_completed_for_seconds=settings.kds_show_completed_for_seconds or 30, + kds_bookings_refresh_seconds=settings.kds_bookings_refresh_seconds or 60, + ) + + +@router.patch("/settings", response_model=KDSSettingsResponse) +async def update_settings( + update: KDSSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update KDS settings for the current kitchen.""" + settings = await get_kds_settings(db, current_user.kitchen_id) + + if not settings: + raise HTTPException(status_code=404, detail="Kitchen settings not found") + + # Update only provided fields + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if hasattr(settings, field): + setattr(settings, field, value) + + settings.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(settings) + + return await get_settings(current_user, db) + + +@router.get("/tickets", response_model=list[KDSTicketResponse]) +async def get_tickets( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Get all active KDS tickets for the current kitchen. + + This endpoint: + 1. Fetches open tickets from SambaPOS via GraphQL + 2. Updates local KDS ticket state + 3. Returns tickets with timing and course state info + """ + settings = await get_kds_settings(db, current_user.kitchen_id) + + if not settings: + raise HTTPException(status_code=404, detail="Kitchen settings not found") + + course_order = settings.kds_course_order or ["Starters", "Mains", "Desserts"] + + # Check if KDS is enabled and configured + kds_url = settings.kds_graphql_url + kds_username = settings.kds_graphql_username + kds_password = settings.kds_graphql_password + kds_client_id = settings.kds_graphql_client_id + + if not all([kds_url, kds_username, kds_password, kds_client_id]): + # Return local tickets only if not configured + return await get_local_tickets(db, current_user.kitchen_id) + + # Fetch from SambaPOS + client = SambaPOSGraphQLClient( + server_url=kds_url, + username=kds_username, + password=kds_password, + client_id=kds_client_id + ) + + result = await client.get_open_tickets() + + if "error" in result: + logger.error(f"KDS GraphQL error: {result['error']}") + # Fall back to local tickets + return await get_local_tickets(db, current_user.kitchen_id) + + # Process tickets + tickets_data = result.get("data", {}).get("getTickets", []) + active_sambapos_ids = set() + response_tickets = [] + + for ticket_data in tickets_data: + transformed = transform_ticket_for_kds(ticket_data) + if not transformed: + continue # Skip tickets with no kitchen orders + + active_sambapos_ids.add(transformed["id"]) + + # Get or create local KDS ticket + kds_ticket = await get_or_create_kds_ticket( + db, current_user.kitchen_id, transformed, course_order + ) + + # Skip tickets that have been bumped (completed) + if kds_ticket.is_bumped: + continue + + # Calculate elapsed time + now = datetime.utcnow() + elapsed = (now - kds_ticket.received_at).total_seconds() + + # Annotate orders with is_sent and is_addition flags + initial_ids = set(kds_ticket.initial_order_ids or []) + ticket_course_states = kds_ticket.course_states or {} + annotated_orders = [] + for o in transformed.get("orders", []): + course_name = o.get("kitchen_course", "Uncategorized") + sent_ids = set(ticket_course_states.get(course_name, {}).get("sent_order_ids", [])) + order_is_sent = o.get("id") in sent_ids + annotated_orders.append(KDSOrderResponse( + **o, + is_sent=order_is_sent, + # Clear addition flag once the order has been sent + is_addition=False if order_is_sent else (o.get("id") not in initial_ids if initial_ids else False), + )) + + # Build response + response_tickets.append(KDSTicketResponse( + id=kds_ticket.id, + sambapos_ticket_id=kds_ticket.sambapos_ticket_id, + ticket_number=kds_ticket.ticket_number, + table_name=kds_ticket.table_name, + covers=kds_ticket.covers, + received_at=kds_ticket.received_at, + time_elapsed_seconds=int(elapsed), + orders=annotated_orders, + orders_by_course=transformed.get("orders_by_course", {}), + course_states=kds_ticket.course_states or {}, + is_bumped=kds_ticket.is_bumped + )) + + # Include SignalR-captured tickets not in the SambaPOS open set. + # These are instantly-closed tickets (free breakfast, bar tabs, etc.) + # persisted by the SignalR listener's _fetch_and_persist_ticket(). + signalr_filter = ( + KDSTicket.sambapos_ticket_id.notin_(active_sambapos_ids) + if active_sambapos_ids + else True + ) + signalr_result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.kitchen_id == current_user.kitchen_id, + KDSTicket.is_active == True, + KDSTicket.is_bumped == False, + signalr_filter, + ) + ).order_by(KDSTicket.received_at) + ) + signalr_tickets = signalr_result.scalars().all() + + now_local = datetime.utcnow() + for kds_ticket in signalr_tickets: + orders = kds_ticket.orders_data or [] + if not orders: + continue + + elapsed = (now_local - kds_ticket.received_at).total_seconds() + + # Group orders by course + orders_by_course = {} + for order in orders: + course = order.get("kitchen_course", "Uncategorized") + if course not in orders_by_course: + orders_by_course[course] = [] + orders_by_course[course].append(order) + + # Annotate orders + initial_ids = set(kds_ticket.initial_order_ids or []) + ticket_course_states = kds_ticket.course_states or {} + annotated_orders = [] + for o in orders: + course_name = o.get("kitchen_course", "Uncategorized") + sent_ids = set(ticket_course_states.get(course_name, {}).get("sent_order_ids", [])) + order_is_sent = o.get("id") in sent_ids + annotated_orders.append(KDSOrderResponse( + **o, + is_sent=order_is_sent, + is_addition=False if order_is_sent else (o.get("id") not in initial_ids if initial_ids else False), + )) + + response_tickets.append(KDSTicketResponse( + id=kds_ticket.id, + sambapos_ticket_id=kds_ticket.sambapos_ticket_id, + ticket_number=kds_ticket.ticket_number, + table_name=kds_ticket.table_name, + covers=kds_ticket.covers, + received_at=kds_ticket.received_at, + time_elapsed_seconds=int(elapsed), + orders=annotated_orders, + orders_by_course=orders_by_course, + course_states=kds_ticket.course_states or {}, + is_bumped=kds_ticket.is_bumped, + )) + + # Mark tickets no longer in SambaPOS as inactive + await mark_closed_tickets(db, current_user.kitchen_id, active_sambapos_ids) + + return response_tickets + + +async def get_local_tickets(db: AsyncSession, kitchen_id: int) -> list[KDSTicketResponse]: + """Get locally stored KDS tickets when GraphQL is unavailable.""" + result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.kitchen_id == kitchen_id, + KDSTicket.is_active == True, + KDSTicket.is_bumped == False + ) + ).order_by(KDSTicket.received_at) + ) + kds_tickets = result.scalars().all() + + response_tickets = [] + now = datetime.utcnow() + + for kds_ticket in kds_tickets: + elapsed = (now - kds_ticket.received_at).total_seconds() + orders = kds_ticket.orders_data or [] + + # Group orders by course + orders_by_course = {} + for order in orders: + course = order.get("kitchen_course", "Uncategorized") + if course not in orders_by_course: + orders_by_course[course] = [] + orders_by_course[course].append(order) + + # Annotate orders with is_sent and is_addition flags + initial_ids = set(kds_ticket.initial_order_ids or []) + ticket_course_states = kds_ticket.course_states or {} + annotated_orders = [] + for o in orders: + course_name = o.get("kitchen_course", "Uncategorized") + sent_ids = set(ticket_course_states.get(course_name, {}).get("sent_order_ids", [])) + order_is_sent = o.get("id") in sent_ids + annotated_orders.append(KDSOrderResponse( + **o, + is_sent=order_is_sent, + # Clear addition flag once the order has been sent + is_addition=False if order_is_sent else (o.get("id") not in initial_ids if initial_ids else False), + )) + + response_tickets.append(KDSTicketResponse( + id=kds_ticket.id, + sambapos_ticket_id=kds_ticket.sambapos_ticket_id, + ticket_number=kds_ticket.ticket_number, + table_name=kds_ticket.table_name, + covers=kds_ticket.covers, + received_at=kds_ticket.received_at, + time_elapsed_seconds=int(elapsed), + orders=annotated_orders, + orders_by_course=orders_by_course, + course_states=kds_ticket.course_states or {}, + is_bumped=kds_ticket.is_bumped + )) + + return response_tickets + + +async def mark_closed_tickets( + db: AsyncSession, + kitchen_id: int, + active_sambapos_ids: set +): + """Mark tickets that are no longer in SambaPOS as inactive. + + Only deactivates tickets that have been bumped (kitchen is done) or + are stale (>4 hours old). Non-bumped tickets are preserved because + they may be instantly-closed tickets (free breakfast, bar tabs) + captured by the SignalR listener that never appear in + getTickets(isClosed: false). The kitchen is the authority on when + a ticket is done (via bumping). + """ + from datetime import timedelta + + result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.kitchen_id == kitchen_id, + KDSTicket.is_active == True + ) + ) + ) + local_tickets = result.scalars().all() + now = datetime.utcnow() + stale_threshold = timedelta(hours=4) + + for ticket in local_tickets: + if ticket.sambapos_ticket_id in active_sambapos_ids: + continue # Still open in SambaPOS — keep active + + # Ticket is NOT in SambaPOS open list + if ticket.is_bumped: + # Kitchen bumped it — safe to deactivate + ticket.is_active = False + ticket.updated_at = now + elif (now - ticket.received_at) > stale_threshold: + # Stale safety net: >4 hours without being bumped + logger.info( + f"KDS: Deactivating stale ticket {ticket.ticket_number} " + f"(SambaPOS ID {ticket.sambapos_ticket_id}, age={now - ticket.received_at})" + ) + ticket.is_active = False + ticket.updated_at = now + # else: not bumped and not stale — keep active for kitchen to process + + await db.commit() + + +# ============================================================================= +# Bookings Panel (Resos integration for KDS) +# ============================================================================= + +@router.get("/bookings", response_model=KDSBookingsResponse) +async def get_kds_bookings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get bookings for the current service period from Resos. + + Determines the active service period from cached opening hours, + then returns all bookings for today matching that period. + """ + from datetime import date, time as dt_time + from models.resos import ResosBooking, ResosOpeningHour + + settings = await get_kds_settings(db, current_user.kitchen_id) + if not settings: + return KDSBookingsResponse() + + today = date.today() + now_time = datetime.now().time() + # Python isoweekday: Mon=1..Sun=7 + day_of_week = today.isoweekday() + + # Build opening hours mapping: resos_id -> {service_type, display_name} + oh_mapping = {} + for entry in (settings.resos_opening_hours_mapping or []): + rid = entry.get("resos_id") + if rid: + oh_mapping[rid] = entry + + # Find current service period from settings or opening hours + display_name = None + matched_resos_ids: list[str] = [] + + # Check for manual override via arrival widget filter (a service_type like "dinner") + service_filter = settings.resos_arrival_widget_service_filter + if service_filter: + # Translate service_type to matching resos_ids via the mapping + for rid, entry in oh_mapping.items(): + if entry.get("service_type", "").lower() == service_filter.lower(): + matched_resos_ids.append(rid) + if not display_name: + display_name = entry.get("display_name", service_filter.title()) + if not display_name: + display_name = service_filter.title() + else: + # Auto-detect from opening hours + result = await db.execute( + select(ResosOpeningHour).where( + and_( + ResosOpeningHour.kitchen_id == current_user.kitchen_id, + ResosOpeningHour.is_special == False, + ) + ) + ) + opening_hours = result.scalars().all() + + # Filter to today's day of week + todays_hours = [] + for oh in opening_hours: + if oh.days_of_week: + days = [int(d.strip()) for d in oh.days_of_week.split(',') if d.strip()] + if day_of_week in days: + todays_hours.append(oh) + else: + # No days_of_week set — check via mapping if this period applies today + # by matching against today's bookings (fallback) + todays_hours.append(oh) + + # Find period where current time falls between start and end + for oh in todays_hours: + if oh.start_time and oh.end_time: + if oh.start_time <= now_time <= oh.end_time: + matched_resos_ids = [oh.resos_opening_hour_id] + entry = oh_mapping.get(oh.resos_opening_hour_id, {}) + display_name = entry.get("display_name") or oh.name + break + + # If no current period, find next upcoming one today + if not matched_resos_ids: + upcoming = [ + oh for oh in todays_hours + if oh.start_time and oh.start_time > now_time + ] + if upcoming: + upcoming.sort(key=lambda oh: oh.start_time) + best = upcoming[0] + matched_resos_ids = [best.resos_opening_hour_id] + entry = oh_mapping.get(best.resos_opening_hour_id, {}) + display_name = entry.get("display_name") or best.name + + # If still nothing (past all periods), use the last period + if not matched_resos_ids and todays_hours: + todays_hours.sort(key=lambda oh: oh.start_time or dt_time(0, 0)) + last = todays_hours[-1] + matched_resos_ids = [last.resos_opening_hour_id] + entry = oh_mapping.get(last.resos_opening_hour_id, {}) + display_name = entry.get("display_name") or last.name + + # Query bookings for today, filtered by matched opening hour IDs + booking_query = select(ResosBooking).where( + and_( + ResosBooking.kitchen_id == current_user.kitchen_id, + ResosBooking.booking_date == today, + ) + ) + if matched_resos_ids: + booking_query = booking_query.where( + ResosBooking.opening_hour_id.in_(matched_resos_ids) + ) + booking_query = booking_query.order_by(ResosBooking.booking_time) + + result = await db.execute(booking_query) + bookings = result.scalars().all() + + total_covers = sum(b.people for b in bookings) + flag_icon_mapping = settings.resos_flag_icon_mapping + + # Query active KDS tickets to match bookings to kitchen stages + course_order = settings.kds_course_order or ["Starters", "Mains", "Desserts"] + kds_tickets_result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.kitchen_id == current_user.kitchen_id, + KDSTicket.is_active == True, + ) + ) + ) + kds_tickets = kds_tickets_result.scalars().all() + + # Build normalized table number -> stage lookup + table_stage_lookup: dict[int, str] = {} + for kt in kds_tickets: + table_num = normalize_table_number(kt.table_name) + if table_num is not None: + stage = derive_kds_stage(kt, course_order) + if stage: + existing = table_stage_lookup.get(table_num) + if existing is None or not kt.is_bumped: + table_stage_lookup[table_num] = stage + + booking_items = [ + KDSBookingItem( + booking_time=b.booking_time.strftime("%H:%M") if b.booking_time else "", + people=b.people, + status=b.status, + table_name=b.table_name, + seating_area=b.seating_area, + is_hotel_guest=b.is_hotel_guest, + is_dbb=b.is_dbb, + is_package=b.is_package, + is_flagged=b.is_flagged, + flag_reasons=b.flag_reasons, + allergies=b.allergies, + kds_stage=table_stage_lookup.get(normalize_table_number(b.table_name)) if b.table_name else None, + ) + for b in bookings + ] + + return KDSBookingsResponse( + period_name=display_name, + total_bookings=len(booking_items), + total_covers=total_covers, + flag_icon_mapping=flag_icon_mapping, + bookings=booking_items, + ) + + +# ============================================================================= +# Course Flow Endpoints +# ============================================================================= + +@router.post("/course-away", response_model=CourseActionResponse) +async def course_away( + request: CourseActionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Mark a course as 'away' (called away from kitchen). + + This starts the prep timer for the course. Only allowed if the previous + course has been marked as 'sent'. + """ + # Get the KDS ticket + result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.id == request.ticket_id, + KDSTicket.kitchen_id == current_user.kitchen_id + ) + ) + ) + kds_ticket = result.scalar_one_or_none() + + if not kds_ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + if kds_ticket.is_bumped: + raise HTTPException(status_code=400, detail="Ticket already completed") + + now = datetime.utcnow() + course_states = dict(kds_ticket.course_states or {}) + + # Validate the course exists + course_state = course_states.get(request.course_name) + if not course_state: + raise HTTPException(status_code=400, detail=f"Course '{request.course_name}' not found in ticket") + + # Validate course is currently pending + if course_state.get("status") != "pending": + raise HTTPException( + status_code=400, + detail=f"Course '{request.course_name}' is already '{course_state.get('status')}'" + ) + + # Validate sequential: previous course must be sent + settings = await get_kds_settings(db, current_user.kitchen_id) + course_order = settings.kds_course_order or ["Starters", "Mains", "Desserts"] + ordered_courses = get_ordered_courses_for_ticket(kds_ticket.orders_data or [], course_order) + + course_idx = None + for i, c in enumerate(ordered_courses): + if c == request.course_name: + course_idx = i + break + + if course_idx is not None and course_idx > 0: + prev_course = ordered_courses[course_idx - 1] + prev_state = course_states.get(prev_course, {}) + if prev_state.get("status") not in ("sent", "cleared"): + raise HTTPException( + status_code=400, + detail=f"Previous course '{prev_course}' must be sent before calling away '{request.course_name}'" + ) + + # Mark previous course as "cleared" (table cleared for next course) + if prev_state.get("status") == "sent": + course_states[prev_course] = { + **prev_state, + "status": "cleared", + "cleared_at": now.isoformat(), + } + # Audit log for cleared + cleared_bump = KDSCourseBump( + ticket_id=kds_ticket.id, + course_name=prev_course, + action="cleared", + bumped_at=now, + bumped_by_user_id=current_user.id, + ) + db.add(cleared_bump) + + # Update course state to "away" + course_states[request.course_name] = { + "status": "away", + "called_away_at": now.isoformat(), + "sent_at": course_state.get("sent_at"), + "sent_by": course_state.get("sent_by"), + "sent_order_ids": course_state.get("sent_order_ids", []), + } + kds_ticket.course_states = course_states + kds_ticket.updated_at = now + + # Create audit record + bump = KDSCourseBump( + ticket_id=kds_ticket.id, + course_name=request.course_name, + action="away", + bumped_at=now, + bumped_by_user_id=current_user.id, + ) + db.add(bump) + + await db.commit() + + return CourseActionResponse( + success=True, + message=f"Course '{request.course_name}' called away", + ticket_id=kds_ticket.id, + course_name=request.course_name, + action="away", + timestamp=now + ) + + +@router.post("/course-sent", response_model=CourseActionResponse) +async def course_sent( + request: CourseActionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Mark a course as 'sent' (food delivered to table). + + This stops the prep timer and starts the away timer. + Course must currently be in 'away' status. + """ + # Get the KDS ticket + result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.id == request.ticket_id, + KDSTicket.kitchen_id == current_user.kitchen_id + ) + ) + ) + kds_ticket = result.scalar_one_or_none() + + if not kds_ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + if kds_ticket.is_bumped: + raise HTTPException(status_code=400, detail="Ticket already completed") + + now = datetime.utcnow() + course_states = dict(kds_ticket.course_states or {}) + + # Validate the course exists + course_state = course_states.get(request.course_name) + if not course_state: + raise HTTPException(status_code=400, detail=f"Course '{request.course_name}' not found in ticket") + + # Validate course is currently away + if course_state.get("status") != "away": + raise HTTPException( + status_code=400, + detail=f"Course '{request.course_name}' must be 'away' before marking as 'sent' (current: '{course_state.get('status')}')" + ) + + # Get previous bump for time calculation + prev_bump_result = await db.execute( + select(KDSCourseBump) + .where(KDSCourseBump.ticket_id == kds_ticket.id) + .order_by(KDSCourseBump.bumped_at.desc()) + .limit(1) + ) + prev_bump = prev_bump_result.scalar_one_or_none() + time_since_previous = None + if prev_bump: + time_since_previous = int((now - prev_bump.bumped_at).total_seconds()) + + # Gather all non-voided order IDs in this course and mark as sent + course_order_ids = [ + o.get("id") for o in (kds_ticket.orders_data or []) + if o.get("kitchen_course") == request.course_name and not o.get("is_voided") + ] + existing_sent = set(course_state.get("sent_order_ids", [])) + all_sent = list(existing_sent | set(course_order_ids)) + + # Update course state to "sent" + course_states[request.course_name] = { + "status": "sent", + "called_away_at": course_state.get("called_away_at"), + "sent_at": now.isoformat(), + "sent_by": current_user.name or current_user.email, + "sent_order_ids": all_sent, + } + kds_ticket.course_states = course_states + kds_ticket.updated_at = now + + # Create audit record + bump = KDSCourseBump( + ticket_id=kds_ticket.id, + course_name=request.course_name, + action="sent", + bumped_at=now, + bumped_by_user_id=current_user.id, + time_since_previous_seconds=time_since_previous + ) + db.add(bump) + + await db.commit() + + return CourseActionResponse( + success=True, + message=f"Course '{request.course_name}' marked as sent", + ticket_id=kds_ticket.id, + course_name=request.course_name, + action="sent", + timestamp=now + ) + + +# Keep backward-compatible bump-course endpoint (acts as course-sent) +@router.post("/bump-course", response_model=CourseBumpResponse) +async def bump_course( + request: CourseBumpRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Legacy endpoint: Bump a course (mark as sent). + Redirects to course-sent logic. + """ + action_req = CourseActionRequest(ticket_id=request.ticket_id, course_name=request.course_name) + result = await course_sent(action_req, current_user, db) + return CourseBumpResponse( + success=result.success, + message=result.message, + ticket_id=result.ticket_id, + course_name=result.course_name, + bumped_at=result.timestamp + ) + + +@router.post("/bump-ticket/{ticket_id}") +async def bump_full_ticket( + ticket_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Bump entire ticket (mark as complete). Used after all courses are sent.""" + result = await db.execute( + select(KDSTicket).where( + and_( + KDSTicket.id == ticket_id, + KDSTicket.kitchen_id == current_user.kitchen_id + ) + ) + ) + kds_ticket = result.scalar_one_or_none() + + if not kds_ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + now = datetime.utcnow() + kds_ticket.is_bumped = True + kds_ticket.bumped_at = now + kds_ticket.updated_at = now + + await db.commit() + + return {"success": True, "message": "Ticket bumped", "ticket_id": ticket_id} + + +@router.post("/sync") +async def trigger_sync( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Manually trigger a sync with SambaPOS.""" + # This just returns the current tickets - the actual sync happens in get_tickets + tickets = await get_tickets(current_user, db) + return { + "success": True, + "message": f"Synced {len(tickets)} active tickets", + "ticket_count": len(tickets) + } + + +@router.get("/debug-graphql") +async def debug_graphql( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Debug endpoint to show raw GraphQL response.""" + settings = await get_kds_settings(db, current_user.kitchen_id) + + if not settings: + return {"error": "Kitchen settings not found"} + + kds_url = settings.kds_graphql_url + kds_username = settings.kds_graphql_username + kds_password = settings.kds_graphql_password + kds_client_id = settings.kds_graphql_client_id + + if not all([kds_url, kds_username, kds_password, kds_client_id]): + return {"error": "KDS not configured"} + + client = SambaPOSGraphQLClient( + server_url=kds_url, + username=kds_username, + password=kds_password, + client_id=kds_client_id + ) + + result = await client.get_open_tickets() + return result + + +@router.get("/test-connection") +async def test_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test the GraphQL connection to SambaPOS.""" + settings = await get_kds_settings(db, current_user.kitchen_id) + + if not settings: + return {"success": False, "error": "Kitchen settings not found"} + + kds_url = settings.kds_graphql_url + kds_username = settings.kds_graphql_username + kds_password = settings.kds_graphql_password + kds_client_id = settings.kds_graphql_client_id + + if not all([kds_url, kds_username, kds_password, kds_client_id]): + return { + "success": False, + "error": "KDS GraphQL settings not configured", + "missing": [ + k for k, v in { + "url": kds_url, + "username": kds_username, + "password": kds_password, + "client_id": kds_client_id + }.items() if not v + ] + } + + client = SambaPOSGraphQLClient( + server_url=kds_url, + username=kds_username, + password=kds_password, + client_id=kds_client_id + ) + + # Try to authenticate + auth_success = await client.authenticate() + if not auth_success: + return {"success": False, "error": "Authentication failed"} + + # Try a simple query + result = await client.graphql_query("{ __typename }") + if "error" in result: + return {"success": False, "error": result["error"]} + + return { + "success": True, + "message": "Connected to SambaPOS GraphQL API", + "server": kds_url + } + + +# ============================================================================= +# Recipe Link (links KDS menu items to dish recipes) +# ============================================================================= + +@router.get("/recipe-link/{menu_item_name}") +async def get_recipe_link( + menu_item_name: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Look up a linked recipe for a KDS order item by menu_item_name. + + Returns a lightweight recipe summary suitable for KDS overlay display: + plating photo, ingredients, key steps, and flag badges. + """ + from models.recipe import Recipe, RecipeIngredient, RecipeStep, RecipeImage + from models.ingredient import Ingredient + from models.food_flag import FoodFlag, RecipeFlag + from sqlalchemy.orm import selectinload + + result = await db.execute( + select(Recipe) + .options(selectinload(Recipe.images)) + .where( + Recipe.kitchen_id == current_user.kitchen_id, + Recipe.kds_menu_item_name == menu_item_name, + Recipe.is_archived == False, + ) + ) + recipe = result.scalar_one_or_none() + if not recipe: + return None + + # Get ingredients + ri_result = await db.execute( + select(RecipeIngredient) + .options(selectinload(RecipeIngredient.ingredient)) + .where(RecipeIngredient.recipe_id == recipe.id) + .order_by(RecipeIngredient.sort_order) + ) + ingredients = [ + { + "name": ri.ingredient.name if ri.ingredient else "?", + "quantity": float(ri.quantity), + "unit": ri.ingredient.standard_unit if ri.ingredient else "", + "notes": ri.notes, + } + for ri in ri_result.scalars().all() + ] + + # Get steps + steps_result = await db.execute( + select(RecipeStep) + .where(RecipeStep.recipe_id == recipe.id) + .order_by(RecipeStep.step_number) + ) + steps = [ + {"step_number": s.step_number, "instruction": s.instruction} + for s in steps_result.scalars().all() + ] + + # Get flags + from api.food_flags import compute_recipe_flags + flags_raw = await compute_recipe_flags(recipe.id, current_user.kitchen_id, db) + flags = [ + {"name": f.flag_name, "code": f.flag_code, "icon": f.flag_icon, "category": f.category_name} + for f in flags_raw if f.is_active + ] + + # Plating image + plating_image = None + for img in (recipe.images or []): + if img.image_type == "plating": + plating_image = {"id": img.id, "caption": img.caption} + break + if not plating_image and recipe.images: + plating_image = {"id": recipe.images[0].id, "caption": recipe.images[0].caption} + + return { + "recipe_id": recipe.id, + "name": recipe.name, + "description": recipe.description, + "batch_portions": recipe.batch_portions, + "prep_time_minutes": recipe.prep_time_minutes, + "cook_time_minutes": recipe.cook_time_minutes, + "plating_image": plating_image, + "ingredients": ingredients, + "steps": steps, + "flags": flags, + } + + +# ============================================================================= +# SSE (Server-Sent Events) for Real-Time Updates +# ============================================================================= + +@router.get("/events") +async def kds_events(request: Request): + """ + Server-Sent Events stream for real-time KDS updates. + + The SignalR listener pushes events here when SambaPOS broadcasts + TICKET_REFRESH messages. Frontend subscribes to this for instant + refresh instead of relying solely on polling. + + No auth required for SSE (connection is long-lived and the + KDS display may not have convenient auth headers for EventSource). + """ + import json + + async def event_generator(): + queue = kds_event_bus.subscribe() + try: + while True: + # Check if client disconnected + if await request.is_disconnected(): + break + try: + event = await asyncio.wait_for(queue.get(), timeout=30) + yield f"data: {json.dumps(event)}\n\n" + except asyncio.TimeoutError: + # Send keepalive comment to prevent connection timeout + yield ": keepalive\n\n" + finally: + kds_event_bus.unsubscribe(queue) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..c80ccd3 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,63 @@ +import os +from types import SimpleNamespace + +from fastapi import Depends, HTTPException, Request, status +from jose import JWTError, jwt + +CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "") +JWT_ALGORITHM = "HS256" +APP_SLUG = os.getenv("APP_SLUG", "kds") + + +async def get_current_user(request: Request): + """ + Verify the stack hnf_session cookie. + Returns a SimpleNamespace so archive kds.py can use current_user.kitchen_id, + current_user.is_admin, etc. without modification. kitchen_id is pinned to 1. + """ + token = request.cookies.get("hnf_session") + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + try: + payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") + + apps = payload.get("apps", []) + if APP_SLUG not in apps: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission for this app") + + prefix = f"{APP_SLUG}:" + raw_caps = payload.get("caps", []) + caps = [c[len(prefix):] for c in raw_caps if isinstance(c, str) and c.startswith(prefix)] + + is_admin = payload.get("is_admin", False) + + return SimpleNamespace( + id=0, + email=payload.get("sub", ""), + username=payload.get("sub", ""), + name=payload.get("name", ""), + display_name=payload.get("name", ""), + is_admin=is_admin, + is_active=True, + kitchen_id=1, + caps=caps, + role="admin" if is_admin else "user", + ) + + +def has_cap(user, cap: str) -> bool: + return user.is_admin or cap in user.caps + + +def require_cap(cap: str): + async def checker(user=Depends(get_current_user)): + if not has_cap(user, cap): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing capability: {cap}", + ) + return user + return checker diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..0557af6 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,38 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://kitchen:kitchen_secret@localhost:5432/kitchen_gp" +) + +# Convert standard postgres URL to asyncpg format +if DATABASE_URL.startswith("postgresql://"): + DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1) + +engine = create_async_engine( + DATABASE_URL, + echo=False, + pool_size=10, # Default is 5 + max_overflow=20, # Default is 10 - allows burst to 30 connections + pool_pre_ping=True # Verify connections are alive before use +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..2a9684b --- /dev/null +++ b/backend/main.py @@ -0,0 +1,66 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from database import engine, Base + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +from api import kds as kds_api + +from migrations.add_kds_tables import run_migration as run_kds_tables +from migrations.add_kds_course_flow import run_migration as run_kds_course_flow +from migrations.add_kds_order_tracking import run_migration as run_kds_order_tracking +from migrations.add_kds_bookings_refresh import run_migration as run_kds_bookings_refresh + +from services.signalr_listener import start_signalr_listener, stop_signalr_listener + +logger = logging.getLogger(__name__) + + +async def _run(name, coro): + try: + await coro() + logger.info(f"{name} migration completed") + except Exception as e: + logger.warning(f"{name} migration warning (may be expected): {e}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Create any SQLAlchemy-mapped KDS tables (idempotent) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # KDS migrations — extend kitchen_db schema with KDS columns/tables. + # Requires kitchen to be deployed first (kitchen_settings must exist). + await _run("KDS tables", run_kds_tables) + await _run("KDS course flow", run_kds_course_flow) + await _run("KDS order tracking", run_kds_order_tracking) + await _run("KDS bookings refresh", run_kds_bookings_refresh) + + await start_signalr_listener() + + yield + + await stop_signalr_listener() + await engine.dispose() + + +app = FastAPI( + title="KDS", + description="Kitchen Display System — SambaPOS SignalR ticket feed, course flow", + version="1.0.0", + lifespan=lifespan, +) + +app.include_router(kds_api.router, prefix="/api/kds", tags=["KDS"]) + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/add_kds_bookings_refresh.py b/backend/migrations/add_kds_bookings_refresh.py new file mode 100644 index 0000000..21256c9 --- /dev/null +++ b/backend/migrations/add_kds_bookings_refresh.py @@ -0,0 +1,38 @@ +""" +Migration: Add KDS bookings refresh interval setting. + +Adds: +- kds_bookings_refresh_seconds column to kitchen_settings +""" + +import asyncio +import logging +from sqlalchemy import text +from database import engine + +logger = logging.getLogger(__name__) + + +async def run_migration(): + """Add KDS bookings refresh interval column.""" + + alter_statements = [ + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_bookings_refresh_seconds INTEGER DEFAULT 60 + """, + ] + + try: + async with engine.begin() as conn: + for sql in alter_statements: + await conn.execute(text(sql)) + logger.info("Added KDS bookings refresh interval column") + except Exception as e: + if "already exists" not in str(e).lower(): + raise + logger.warning(f"KDS bookings refresh migration: {e}") + + +if __name__ == "__main__": + asyncio.run(run_migration()) diff --git a/backend/migrations/add_kds_course_flow.py b/backend/migrations/add_kds_course_flow.py new file mode 100644 index 0000000..1f7c3a8 --- /dev/null +++ b/backend/migrations/add_kds_course_flow.py @@ -0,0 +1,54 @@ +""" +Migration: Add KDS course flow settings (away timer thresholds) +and action column to course bumps audit trail. + +Adds: +- kds_away_timer_green/amber/red_seconds to kitchen_settings +- action column to kds_course_bumps +""" + +import asyncio +import logging +from sqlalchemy import text +from database import engine + +logger = logging.getLogger(__name__) + + +async def run_migration(): + """Add KDS course flow columns.""" + + alter_statements = [ + # Away timer thresholds (time since food sent to table) + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_away_timer_green_seconds INTEGER DEFAULT 600 + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_away_timer_amber_seconds INTEGER DEFAULT 900 + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_away_timer_red_seconds INTEGER DEFAULT 1200 + """, + # Action column on course bumps audit trail ('away' or 'sent') + """ + ALTER TABLE kds_course_bumps + ADD COLUMN IF NOT EXISTS action VARCHAR(20) DEFAULT 'sent' + """, + ] + + try: + async with engine.begin() as conn: + for sql in alter_statements: + await conn.execute(text(sql)) + logger.info("Added KDS course flow columns") + except Exception as e: + if "already exists" not in str(e).lower(): + raise + logger.warning(f"KDS course flow migration: {e}") + + +if __name__ == "__main__": + asyncio.run(run_migration()) diff --git a/backend/migrations/add_kds_order_tracking.py b/backend/migrations/add_kds_order_tracking.py new file mode 100644 index 0000000..b5261a2 --- /dev/null +++ b/backend/migrations/add_kds_order_tracking.py @@ -0,0 +1,39 @@ +""" +Migration: Add KDS per-order tracking column. + +Adds: +- initial_order_ids JSONB column to kds_tickets (captures order IDs at ticket creation + for detecting +ADDITION orders added later) +""" + +import asyncio +import logging +from sqlalchemy import text +from database import engine + +logger = logging.getLogger(__name__) + + +async def run_migration(): + """Add KDS order tracking column.""" + + alter_statements = [ + """ + ALTER TABLE kds_tickets + ADD COLUMN IF NOT EXISTS initial_order_ids JSONB + """, + ] + + try: + async with engine.begin() as conn: + for sql in alter_statements: + await conn.execute(text(sql)) + logger.info("Added KDS order tracking column (initial_order_ids)") + except Exception as e: + if "already exists" not in str(e).lower(): + raise + logger.warning(f"KDS order tracking migration: {e}") + + +if __name__ == "__main__": + asyncio.run(run_migration()) diff --git a/backend/migrations/add_kds_tables.py b/backend/migrations/add_kds_tables.py new file mode 100644 index 0000000..58b440d --- /dev/null +++ b/backend/migrations/add_kds_tables.py @@ -0,0 +1,124 @@ +""" +Migration: Add KDS (Kitchen Display System) tables and settings + +Creates: +- kds_tickets: Local ticket state tracking +- kds_course_bumps: Course bump audit trail +- KDS settings columns in kitchen_settings +""" + +import logging +from sqlalchemy import text +from database import engine + +logger = logging.getLogger(__name__) + + +async def run_migration(): + """Add KDS tables and settings columns.""" + migrations = [ + # KDS settings columns in kitchen_settings + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_enabled BOOLEAN DEFAULT FALSE + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_graphql_url VARCHAR(500) + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_graphql_username VARCHAR(255) + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_graphql_password VARCHAR(500) + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_graphql_client_id VARCHAR(255) + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_poll_interval_seconds INTEGER DEFAULT 6000 + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_timer_green_seconds INTEGER DEFAULT 300 + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_timer_amber_seconds INTEGER DEFAULT 600 + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_timer_red_seconds INTEGER DEFAULT 900 + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_course_order JSONB DEFAULT '["Starters", "Mains", "Desserts"]'::jsonb + """, + """ + ALTER TABLE kitchen_settings + ADD COLUMN IF NOT EXISTS kds_show_completed_for_seconds INTEGER DEFAULT 30 + """, + + # KDS Tickets table + """ + CREATE TABLE IF NOT EXISTS kds_tickets ( + id SERIAL PRIMARY KEY, + kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), + sambapos_ticket_id INTEGER NOT NULL, + sambapos_ticket_uid VARCHAR(100), + ticket_number VARCHAR(50) NOT NULL, + table_name VARCHAR(100), + covers INTEGER, + total_amount FLOAT, + received_at TIMESTAMP DEFAULT NOW(), + last_sambapos_update TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + is_bumped BOOLEAN DEFAULT FALSE, + bumped_at TIMESTAMP, + course_states JSONB DEFAULT '{}'::jsonb, + orders_data JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_kds_tickets_kitchen_id ON kds_tickets(kitchen_id) + """, + """ + CREATE INDEX IF NOT EXISTS idx_kds_tickets_sambapos_id ON kds_tickets(sambapos_ticket_id) + """, + """ + CREATE INDEX IF NOT EXISTS idx_kds_tickets_active ON kds_tickets(kitchen_id, is_active) + """, + + # KDS Course Bumps table + """ + CREATE TABLE IF NOT EXISTS kds_course_bumps ( + id SERIAL PRIMARY KEY, + ticket_id INTEGER NOT NULL REFERENCES kds_tickets(id) ON DELETE CASCADE, + course_name VARCHAR(100) NOT NULL, + bumped_at TIMESTAMP DEFAULT NOW(), + bumped_by_user_id INTEGER REFERENCES users(id), + time_since_previous_seconds INTEGER + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_kds_course_bumps_ticket_id ON kds_course_bumps(ticket_id) + """, + ] + + for sql in migrations: + try: + async with engine.begin() as conn: + await conn.execute(text(sql.strip())) + logger.info(f"KDS Migration executed: {sql.strip()[:60]}...") + except Exception as e: + error_str = str(e).lower() + if "already exists" in error_str or "duplicate" in error_str: + logger.info(f"KDS Migration: already exists, skipping") + else: + logger.warning(f"KDS Migration warning: {e}") diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/kds.py b/backend/models/kds.py new file mode 100644 index 0000000..fd3f70d --- /dev/null +++ b/backend/models/kds.py @@ -0,0 +1,88 @@ +""" +KDS (Kitchen Display System) Models + +Local state tracking for kitchen orders, course bumping, and display. +""" + +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean, Integer, Float +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from database import Base + + +class KDSTicket(Base): + """ + Local tracking of SambaPOS tickets for KDS display. + + Stores the current state of each ticket being displayed on KDS, + including course progress and timing. + """ + __tablename__ = "kds_tickets" + + id: Mapped[int] = mapped_column(primary_key=True) + kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), index=True) + + # SambaPOS ticket reference + sambapos_ticket_id: Mapped[int] = mapped_column(Integer, index=True) + sambapos_ticket_uid: Mapped[str | None] = mapped_column(String(100), nullable=True) + ticket_number: Mapped[str] = mapped_column(String(50)) + + # Ticket info from SambaPOS + table_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + covers: Mapped[int | None] = mapped_column(Integer, nullable=True) + total_amount: Mapped[float | None] = mapped_column(Float, nullable=True) + + # Timing + received_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + last_sambapos_update: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Local state tracking + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_bumped: Mapped[bool] = mapped_column(Boolean, default=False) # Fully bumped/completed + bumped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Current course state (which courses have been bumped) + # Format: {"Starters": {"bumped": true, "bumped_at": "2025-01-25T12:00:00"}, ...} + course_states: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict) + + # Cached order data (refreshed on each poll) + orders_data: Mapped[list | None] = mapped_column(JSONB, nullable=True) + + # Order IDs captured at ticket creation (for detecting +ADDITION orders later) + initial_order_ids: Mapped[list | None] = mapped_column(JSONB, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + kitchen: Mapped["Kitchen"] = relationship("Kitchen") + course_bumps: Mapped[list["KDSCourseBump"]] = relationship("KDSCourseBump", back_populates="ticket", cascade="all, delete-orphan") + + +class KDSCourseBump(Base): + """ + Track individual course bumps for audit trail. + + Records when each course was bumped for a ticket. + """ + __tablename__ = "kds_course_bumps" + + id: Mapped[int] = mapped_column(primary_key=True) + ticket_id: Mapped[int] = mapped_column(ForeignKey("kds_tickets.id", ondelete="CASCADE"), index=True) + + course_name: Mapped[str] = mapped_column(String(100)) # e.g., "Starters", "Mains", "Desserts" + action: Mapped[str] = mapped_column(String(20), default="sent") # "away" or "sent" + bumped_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + bumped_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + # Time since previous course bump (for analytics) + time_since_previous_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Relationships + ticket: Mapped["KDSTicket"] = relationship("KDSTicket", back_populates="course_bumps") + bumped_by: Mapped["User"] = relationship("User") + + +# Forward references +from .user import Kitchen, User diff --git a/backend/models/resos.py b/backend/models/resos.py new file mode 100644 index 0000000..cce5766 --- /dev/null +++ b/backend/models/resos.py @@ -0,0 +1,145 @@ +from datetime import datetime, date, time +from sqlalchemy import String, DateTime, Date, Time, ForeignKey, Boolean, Text, Integer, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from database import Base + + +class ResosBooking(Base): + """Individual booking records from Resos API""" + __tablename__ = "resos_bookings" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False) + resos_booking_id: Mapped[str] = mapped_column(String(255), nullable=False) + + # Booking details + booking_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + booking_time: Mapped[time] = mapped_column(Time, nullable=False) + people: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(50), nullable=False) + + # Guest info (non-PII) + seating_area: Mapped[str | None] = mapped_column(String(255), nullable=True) + table_name: Mapped[str | None] = mapped_column(String(100), nullable=True) # Phase 8.1: Table from Resos + + # Custom fields + hotel_booking_number: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_hotel_guest: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + is_dbb: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + is_package: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + exclude_flag: Mapped[str | None] = mapped_column(String(500), nullable=True) + allergies: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Notes + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Metadata + booked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + opening_hour_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + opening_hour_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Flags + is_flagged: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + flag_reasons: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Sync metadata + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + is_forecast: Mapped[bool] = mapped_column(Boolean, default=False) + + # Relationships + kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_bookings") + + __table_args__ = ( + UniqueConstraint('kitchen_id', 'resos_booking_id', name='uq_resos_booking'), + ) + + +class ResosDailyStats(Base): + """Aggregated daily booking statistics""" + __tablename__ = "resos_daily_stats" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + + # Overall totals + total_bookings: Mapped[int] = mapped_column(Integer, default=0) + total_covers: Mapped[int] = mapped_column(Integer, default=0) + + # By service period (JSONB) + # Format: [{"period": "Lunch", "bookings": 15, "covers": 32}, ...] + service_breakdown: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + # Flags + flagged_booking_count: Mapped[int] = mapped_column(Integer, default=0) + + # Unique flag types present on this day (JSONB list) + # Format: ["allergies", "large_group", "note_keyword_birthday"] + unique_flag_types: Mapped[list | None] = mapped_column(JSONB, nullable=True) + + # Consolidated booking data for quick access (JSONB) + # Format: [{"time": "19:00", "people": 2, "period": "Dinner", "booked_at": "2026-01-15T10:30:00", "is_flagged": true, "status": "confirmed"}, ...] + bookings_summary: Mapped[list | None] = mapped_column(JSONB, nullable=True) + + # Metadata + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + is_forecast: Mapped[bool] = mapped_column(Boolean, default=False) + + # Relationships + kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_daily_stats") + + __table_args__ = ( + UniqueConstraint('kitchen_id', 'date', name='uq_resos_daily_stat'), + ) + + +class ResosOpeningHour(Base): + """Cached service period definitions from Resos""" + __tablename__ = "resos_opening_hours" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True) + resos_opening_hour_id: Mapped[str] = mapped_column(String(255), nullable=False) + + # Period details + name: Mapped[str] = mapped_column(String(255), nullable=False) + start_time: Mapped[time | None] = mapped_column(Time, nullable=True) + end_time: Mapped[time | None] = mapped_column(Time, nullable=True) + days_of_week: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Metadata + is_special: Mapped[bool] = mapped_column(Boolean, default=False) + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_opening_hours") + + __table_args__ = ( + UniqueConstraint('kitchen_id', 'resos_opening_hour_id', name='uq_resos_opening_hour'), + ) + + +class ResosSyncLog(Base): + """Audit trail for sync operations""" + __tablename__ = "resos_sync_log" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True) + + sync_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'forecast', 'historical', 'daily' + status: Mapped[str] = mapped_column(String(20), nullable=False) # 'running', 'success', 'failed' + + date_from: Mapped[date | None] = mapped_column(Date, nullable=True) + date_to: Mapped[date | None] = mapped_column(Date, nullable=True) + + bookings_fetched: Mapped[int] = mapped_column(Integer, default=0) + bookings_flagged: Mapped[int] = mapped_column(Integer, default=0) + + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Relationships + kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_sync_logs") diff --git a/backend/models/settings.py b/backend/models/settings.py new file mode 100644 index 0000000..06b978e --- /dev/null +++ b/backend/models/settings.py @@ -0,0 +1,238 @@ +from datetime import datetime +from decimal import Decimal +from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean, Numeric, Integer +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from database import Base + + +class KitchenSettings(Base): + """Kitchen-level settings including OCR and Newbook configuration""" + __tablename__ = "kitchen_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), unique=True) + + # Azure Document Intelligence settings + azure_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True) + azure_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # OCR post-processing options + ocr_clean_product_codes: Mapped[bool] = mapped_column(Boolean, default=False) # Strip section headers from product codes + ocr_filter_subtotal_rows: Mapped[bool] = mapped_column(Boolean, default=False) # Filter subtotal/total rows from line items + ocr_use_weight_as_quantity: Mapped[bool] = mapped_column(Boolean, default=False) # For KG items, use weight as quantity when it matches total + + # Newbook API settings + newbook_api_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + newbook_api_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + newbook_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + newbook_api_region: Mapped[str | None] = mapped_column(String(10), nullable=True) # au, ap, eu, us + newbook_instance_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Newbook sync configuration + newbook_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + newbook_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + # Separate sync interval for next 7 days (in minutes, default 15) + newbook_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15) + newbook_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + newbook_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Newbook allocation GL mapping (CSV-style, e.g. "4100,4101,4102") + newbook_breakfast_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True) + newbook_dinner_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # VAT rates for calculating net from gross (e.g., 0.10 for 10% VAT) + newbook_breakfast_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10")) + newbook_dinner_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10")) + + # Resos API Configuration + resos_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + resos_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + resos_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + # Separate sync interval for next 7 days (in minutes, default 15) + resos_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15) + resos_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + resos_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Resos Flagging Configuration + resos_large_group_threshold: Mapped[int] = mapped_column(Integer, default=8) + resos_note_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "birthday|anniversary|proposal" + resos_allergy_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "gluten|dairy|nut|shellfish" + + # Resos Custom Field & Period Mapping + resos_custom_field_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # Format: {"booking_number": "field_id_123", ...} + resos_opening_hours_mapping: Mapped[list | None] = mapped_column(JSONB, nullable=True) # Format: [{"resos_id": "abc123", "display_name": "Lunch", "actual_end": "14:30"}, ...] + + # Resos SambaPOS Integration + resos_restaurant_table_entities: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated entity names + + # Manual Breakfast Configuration (not in Resos) + resos_enable_manual_breakfast: Mapped[bool] = mapped_column(Boolean, default=False) + # Format: [{"day": 1, "start": "07:00", "end": "11:00"}, ...] where day: 1=Monday, 7=Sunday + resos_manual_breakfast_periods: Mapped[list | None] = mapped_column(JSONB, nullable=True) + + # Resos Flag Icon Mapping (customizable icons for each flag type) + # Format: {"allergies": "🦀", "large_group": "⚠️", "birthday": "🎂", "anniversary": "💍", ...} + resos_flag_icon_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + # Resos Arrival Widget Service Filter (filter arrivals widget by service type from mapping) + resos_arrival_widget_service_filter: Mapped[str | None] = mapped_column(String(50), nullable=True) # service_type: breakfast/lunch/dinner/other + + # SambaPOS MSSQL Connection + sambapos_db_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + sambapos_db_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=1433) + sambapos_db_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + sambapos_db_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + sambapos_db_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + # SambaPOS tracked categories (comma-separated list, order preserved for display) + sambapos_tracked_categories: Mapped[str | None] = mapped_column(String(1000), nullable=True) + # SambaPOS excluded menu items (comma-separated list of menu item names to exclude from reports) + sambapos_excluded_items: Mapped[str | None] = mapped_column(Text, nullable=True) + # Phase 8.1: GL code configuration for food/beverage split + sambapos_food_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for food items + sambapos_beverage_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for beverage items + + # SMTP email configuration + smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=587) + smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True) + smtp_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_from_name: Mapped[str | None] = mapped_column(String(255), nullable=True, default="Kitchen Invoice System") + + # Support request email (where screenshot reports are sent) + support_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Dext integration + dext_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + dext_include_notes: Mapped[bool] = mapped_column(Boolean, default=True) + dext_include_non_stock: Mapped[bool] = mapped_column(Boolean, default=True) + dext_auto_send_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + dext_manual_send_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + dext_include_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Include PDF annotations when sending to Dext + + # Nextcloud settings + nextcloud_host: Mapped[str | None] = mapped_column(String(500), nullable=True) + nextcloud_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + nextcloud_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + nextcloud_base_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Kitchen Invoices") + nextcloud_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + nextcloud_delete_local: Mapped[bool] = mapped_column(Boolean, default=False) # Delete local file after successful archive + + # Backup settings + backup_frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, default="manual") # daily, weekly, manual + backup_retention_count: Mapped[int] = mapped_column(Integer, default=7) + backup_destination: Mapped[str | None] = mapped_column(String(20), nullable=True, default="local") # local, nextcloud, smb + backup_time: Mapped[str | None] = mapped_column(String(5), nullable=True, default="03:00") + + # Nextcloud backup path (when backup_destination = "nextcloud") + backup_nextcloud_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Backups") + + # SMB backup settings (used when backup_destination = "smb") + backup_smb_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + backup_smb_share: Mapped[str | None] = mapped_column(String(255), nullable=True) + backup_smb_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + backup_smb_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + backup_smb_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/backups") + + # Last backup tracking + backup_last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + backup_last_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + backup_last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + # IMAP Email Inbox settings + imap_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + imap_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=993) + imap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True) + imap_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + imap_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + imap_folder: Mapped[str | None] = mapped_column(String(255), nullable=True, default="INBOX") + imap_poll_interval: Mapped[int] = mapped_column(Integer, default=15) # minutes + imap_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + imap_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.50")) + imap_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # General settings + currency_symbol: Mapped[str] = mapped_column(String(5), default="£") + date_format: Mapped[str] = mapped_column(String(20), default="DD/MM/YYYY") + + # Warning thresholds + high_quantity_threshold: Mapped[int] = mapped_column(default=100) # Warn if qty > this value + + # PDF annotation settings + pdf_annotations_enabled: Mapped[bool] = mapped_column(Boolean, default=True) # Enable adding annotations to PDFs + pdf_preview_show_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Show annotations in preview window + + # Price change detection settings + price_change_lookback_days: Mapped[int] = mapped_column(Integer, default=30) # Days to look back for price comparison + price_change_amber_threshold: Mapped[int] = mapped_column(Integer, default=10) # % change for amber warning + price_change_red_threshold: Mapped[int] = mapped_column(Integer, default=20) # % change for red alert + + # Admin-only page restrictions (comma-separated list of page paths, e.g., "/settings,/suppliers") + admin_restricted_pages: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Forecast API integration (Spend Budget feature) + forecast_api_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + forecast_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Budget settings + budget_gp_target: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True, default=Decimal("65.00")) + budget_lookback_weeks: Mapped[int] = mapped_column(Integer, default=4) + + # KDS (Kitchen Display System) settings + kds_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + kds_graphql_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + kds_graphql_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + kds_graphql_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + kds_graphql_client_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + kds_poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=6000) + kds_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=300) + kds_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=600) + kds_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=900) + kds_course_order: Mapped[list | None] = mapped_column(JSONB, nullable=True, default=["Starters", "Mains", "Desserts"]) + kds_show_completed_for_seconds: Mapped[int] = mapped_column(Integer, default=30) + + # Away timer thresholds (time since food sent to table - "eating" phase) + kds_away_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=600) # 10 minutes + kds_away_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=900) # 15 minutes + kds_away_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=1200) # 20 minutes + kds_bookings_refresh_seconds: Mapped[int] = mapped_column(Integer, default=60) + + # Cost distribution settings + cost_distribution_max_days: Mapped[int] = mapped_column(Integer, default=90) + + # LLM integration (Claude) — see LLM-MANIFEST.md for removal instructions + llm_enabled: Mapped[bool] = mapped_column(Boolean, default=False) # Master kill switch — False = zero AI footprint + anthropic_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + llm_model: Mapped[str | None] = mapped_column(String(100), nullable=True, default="claude-haiku-4-5-20251001") + llm_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.80")) + llm_monthly_token_limit: Mapped[int] = mapped_column(Integer, default=500000) # ~$1.25/month on Haiku + llm_features_enabled: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default={ + "label_parsing": True, "invoice_assist": True, "ingredient_match": True, + "recipe_scanning": True, "line_item_reconciliation": True, "menu_description": True, + "dispute_email": True, "duplicate_detection": True, "supplier_alias": True, "yield_estimation": True + }) + + # Internal API key (for in-house apps like menu display plugin) + api_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + api_key_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + + # Kitchen details (for PO letterhead) + kitchen_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + kitchen_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True) + kitchen_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True) + kitchen_city: Mapped[str | None] = mapped_column(String(100), nullable=True) + kitchen_postcode: Mapped[str | None] = mapped_column(String(20), nullable=True) + kitchen_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + kitchen_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="settings") + + +# Forward reference +from .user import Kitchen diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..5b95ebe --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,14 @@ +""" +Type stub — kds.py has `from models.user import User` for the type annotation on +Depends(get_current_user). At runtime the dependency returns a SimpleNamespace from +auth.py; this class satisfies the import without pulling in the full SQLAlchemy model. +""" + + +class User: + id: int + email: str + name: str + is_admin: bool + kitchen_id: int + caps: list diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3d18368 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +# Web framework +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# Database — connects to kitchen_db (shared schema; KDS adds its own tables/columns) +sqlalchemy==2.0.25 +asyncpg==0.29.0 + +# Authentication — python-jose verifies the central hnf_session JWT +python-jose[cryptography]==3.3.0 + +# HTTP — SignalR WebSocket upgrade + SambaPOS GraphQL +httpx==0.27.0 + +# Utilities +pydantic==2.5.3 +python-dotenv==1.0.0 +aiofiles==23.2.1 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/kds_graphql.py b/backend/services/kds_graphql.py new file mode 100644 index 0000000..64ae69d --- /dev/null +++ b/backend/services/kds_graphql.py @@ -0,0 +1,382 @@ +""" +SambaPOS GraphQL Client for KDS + +Connects to SambaPOS Message Server GraphQL API to fetch open tickets +with kitchen orders for the Kitchen Display System. +""" + +import logging +from typing import Optional +from datetime import datetime +import httpx + +logger = logging.getLogger(__name__) + + +class SambaPOSGraphQLClient: + """Client for SambaPOS Message Server GraphQL API.""" + + def __init__( + self, + server_url: str, + username: str, + password: str, + client_id: str + ): + self.server_url = server_url.rstrip('/') + self.username = username + self.password = password + self.client_id = client_id + self.access_token: Optional[str] = None + self.token_expires_at: Optional[datetime] = None + + async def authenticate(self) -> bool: + """ + Authenticate with SambaPOS and get access token. + + POST /Token with: + grant_type=password + username= + password= + client_id= + """ + token_url = f"{self.server_url}/Token" + + data = { + "grant_type": "password", + "username": self.username, + "password": self.password, + "client_id": self.client_id, + } + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + token_url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + if response.status_code == 200: + result = response.json() + self.access_token = result.get("access_token") + expires_in = result.get("expires_in", 86400) + self.token_expires_at = datetime.utcnow() + logger.info(f"KDS: Authenticated with SambaPOS (expires in {expires_in}s)") + return True + else: + logger.error(f"KDS: Authentication failed: {response.status_code} - {response.text}") + return False + except httpx.ConnectError: + logger.error(f"KDS: Could not connect to {token_url}") + return False + except Exception as e: + logger.error(f"KDS: Authentication error: {e}") + return False + + async def ensure_authenticated(self) -> bool: + """Ensure we have a valid token, re-authenticating if needed.""" + if not self.access_token: + return await self.authenticate() + return True + + async def graphql_query(self, query: str, variables: Optional[dict] = None) -> dict: + """Execute a GraphQL query against SambaPOS.""" + if not await self.ensure_authenticated(): + return {"error": "Authentication failed"} + + graphql_url = f"{self.server_url}/api/graphql" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}" + } + + payload = { + "query": query, + "variables": variables, + "operationName": None + } + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + graphql_url, + json=payload, + headers=headers + ) + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + # Token expired, try re-auth + self.access_token = None + if await self.authenticate(): + return await self.graphql_query(query, variables) + return {"error": "Re-authentication failed"} + else: + return {"error": f"HTTP {response.status_code}: {response.text}"} + except httpx.ConnectError: + return {"error": f"Could not connect to {graphql_url}"} + except Exception as e: + return {"error": str(e)} + + async def get_open_tickets(self) -> dict: + """ + Query for open (not closed) tickets with full order details. + + Returns tickets with: + - Ticket info (id, number, date, table) + - Orders with Kitchen Course and Kitchen Print states + """ + query = """ + { + getTickets(isClosed: false, orderBy: date) { + id + uid + number + date + lastUpdateTime + totalAmount + remainingAmount + note + tags { + tag + tagName + } + states { + stateName + state + } + orders { + id + uid + name + portion + quantity + price + priceTag + date + tags { + tag + tagName + quantity + } + states { + stateName + state + stateValue + } + } + entities { + type + name + } + } + } + """ + return await self.graphql_query(query) + + async def get_ticket_by_id(self, ticket_id: int) -> dict: + """Query for a specific ticket by ID. + + Uses inline ID rather than GraphQL variables because the SambaPOS + Message Server has a NullReferenceException bug when processing + variable bindings for getTicket. + """ + query = f""" + {{ + getTicket(id: {int(ticket_id)}) {{ + id + uid + number + date + lastUpdateTime + totalAmount + tags {{ + tag + tagName + }} + states {{ + stateName + state + }} + orders {{ + id + uid + name + portion + quantity + price + date + tags {{ + tag + tagName + quantity + }} + states {{ + stateName + state + stateValue + }} + }} + entities {{ + type + name + }} + }} + }} + """ + return await self.graphql_query(query) + + +def parse_kitchen_course(order: dict) -> Optional[str]: + """Extract Kitchen Course from order states.""" + states = order.get("states", []) + for state in states: + if state.get("stateName") == "Kitchen Course": + return state.get("state") + return None + + +def parse_order_status(order: dict) -> str: + """Extract Status state from order (e.g., Submitted, New).""" + states = order.get("states", []) + for state in states: + if state.get("stateName") == "Status": + return state.get("state", "Unknown") + return "Unknown" + + +def parse_kitchen_print_state(order: dict) -> Optional[str]: + """Extract Kitchen Print state from order.""" + states = order.get("states", []) + for state in states: + if state.get("stateName") == "Kitchen Print": + return state.get("state") + return None + + +def parse_gstatus(order: dict) -> tuple[Optional[str], Optional[str]]: + """Extract GStatus state and timestamp from order (used for void detection).""" + states = order.get("states", []) + for state in states: + if state.get("stateName") == "GStatus": + return state.get("state"), state.get("stateDateTime") + return None, None + + +def get_table_name(ticket: dict) -> Optional[str]: + """Extract table name from ticket entities.""" + entities = ticket.get("entities", []) + for entity in entities: + if entity.get("type") == "Tables": + return entity.get("name") + return None + + +def transform_ticket_for_kds(ticket: dict) -> dict: + """ + Transform a SambaPOS ticket into KDS-friendly format. + + Groups orders by Kitchen Course and filters for kitchen-relevant items. + """ + table_name = get_table_name(ticket) + + # Group orders by kitchen course + orders_by_course = {} + all_orders = [] + deferred_voided = [] + earliest_kitchen_order_date = None # Track earliest kitchen-printable order time + + for order in ticket.get("orders", []): + kitchen_course = parse_kitchen_course(order) or "Uncategorized" + order_status = parse_order_status(order) + kitchen_print = parse_kitchen_print_state(order) + gstatus, gstatus_datetime = parse_gstatus(order) + + # Log order states for debugging + logger.debug(f"Order '{order.get('name')}': status={order_status}, gstatus={gstatus}, kitchen_print={kitchen_print}") + + # Must have Kitchen Print state set (meaning it's a kitchen item) + if not kitchen_print: + continue + + # Determine if item is voided (show with strikethrough) + is_voided = ( + kitchen_print in ["Canceled", "Void"] or + gstatus in ["Void", "Cancelled", "Canceled"] or + order_status in ["Void", "Cancelled", "Canceled"] + ) + + # Get void timestamp if available + voided_at = gstatus_datetime if is_voided and gstatus in ["Void", "Cancelled", "Canceled"] else None + + # Skip non-voided items that are not submitted (e.g., "New" status) + if not is_voided and order_status not in ["Submitted"]: + logger.debug(f"Skipping order '{order.get('name')}' - status is '{order_status}', not 'Submitted'") + continue + + # Extract order tags (modifiers like "Rare", "No sauce", etc.) + order_tags = order.get("tags", []) + + order_data = { + "id": order.get("id"), + "uid": order.get("uid"), + "name": order.get("name"), + "portion": order.get("portion"), + "quantity": order.get("quantity"), + "price": order.get("price"), + "kitchen_course": kitchen_course, + "status": order_status, + "kitchen_print": kitchen_print, + "is_voided": is_voided, + "voided_at": voided_at, + "tags": order_tags, + } + + if is_voided: + # Defer voided orders - only add to existing course groups later + deferred_voided.append((kitchen_course, order_data)) + else: + if kitchen_course not in orders_by_course: + orders_by_course[kitchen_course] = [] + orders_by_course[kitchen_course].append(order_data) + all_orders.append(order_data) + + # Track earliest kitchen-printable order date + order_date = order.get("date") + if order_date: + if earliest_kitchen_order_date is None or order_date < earliest_kitchen_order_date: + earliest_kitchen_order_date = order_date + + # Add voided orders only to course groups that already exist (avoids "Uncategorized" ghost courses) + for course, order_data in deferred_voided: + if course in orders_by_course: + orders_by_course[course].append(order_data) + all_orders.append(order_data) + + # Skip tickets with no kitchen orders + if not all_orders: + return None + + # Get covers from tags + covers = None + for tag in ticket.get("tags", []): + if tag.get("tagName") == "Covers": + try: + covers = int(tag.get("tag")) + except (ValueError, TypeError): + pass + + return { + "id": ticket.get("id"), + "uid": ticket.get("uid"), + "number": ticket.get("number"), + "date": ticket.get("date"), + "last_update": ticket.get("lastUpdateTime"), + "table": table_name, + "covers": covers, + "total_amount": ticket.get("totalAmount"), + "orders": all_orders, + "orders_by_course": orders_by_course, + "submitted_at": earliest_kitchen_order_date or ticket.get("date"), # First kitchen order time, fallback to ticket date + } diff --git a/backend/services/signalr_listener.py b/backend/services/signalr_listener.py new file mode 100644 index 0000000..e391ccc --- /dev/null +++ b/backend/services/signalr_listener.py @@ -0,0 +1,296 @@ +""" +SambaPOS SignalR Listener for KDS + +Connects to SambaPOS Message Server via SignalR 2.x WebSocket and listens +for TICKET_REFRESH broadcasts. When received, fetches the specific ticket +by ID (works even for closed/zero-total tickets) and creates/updates +KDS entries for real-time display. + +This solves the problem of tickets that close instantly (e.g. free breakfast +for residents, bar orders paid immediately) not appearing in KDS polling. + +SignalR 2.x Protocol: +1. GET /signalr/negotiate - get connection token +2. WS /signalr/connect?transport=webSockets&connectionToken=... - WebSocket +3. Messages arrive as JSON: {"C": "...", "M": [{...}]} + - TICKET_REFRESH: {"H": "Default", "M": "update", "A": ["guid:ticketId"]} +""" + +import asyncio +import json +import logging +import urllib.parse +from typing import Optional +from datetime import datetime + +import httpx + +logger = logging.getLogger(__name__) + + +class KDSEventBus: + """Simple pub/sub for notifying SSE subscribers of KDS events.""" + + def __init__(self): + self._subscribers: list[asyncio.Queue] = [] + + def subscribe(self) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue() + self._subscribers.append(q) + return q + + def unsubscribe(self, q: asyncio.Queue): + if q in self._subscribers: + self._subscribers.remove(q) + + async def publish(self, event: dict): + for q in list(self._subscribers): + try: + q.put_nowait(event) + except asyncio.QueueFull: + pass + + +# Global event bus - imported by kds.py for the SSE endpoint +kds_event_bus = KDSEventBus() + + +class SignalRListener: + """Background listener for SambaPOS SignalR broadcasts.""" + + def __init__( + self, + base_url: str, + graphql_username: str, + graphql_password: str, + graphql_client_id: str, + kitchen_id: int, + course_order: list, + ): + self.base_url = base_url.rstrip('/') + self.graphql_username = graphql_username + self.graphql_password = graphql_password + self.graphql_client_id = graphql_client_id + self.kitchen_id = kitchen_id + self.course_order = course_order + self._running = False + self._task: Optional[asyncio.Task] = None + + def _get_ws_base(self) -> str: + """Convert HTTP URL to WS URL.""" + return self.base_url.replace('http://', 'ws://').replace('https://', 'wss://') + + async def _negotiate(self) -> Optional[str]: + """Negotiate SignalR connection and get token.""" + url = f"{self.base_url}/signalr/negotiate" + params = { + "clientProtocol": "1.5", + "connectionData": json.dumps([{"name": "default"}]) + } + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url, params=params) + if response.status_code == 200: + data = response.json() + return data.get("ConnectionToken") + else: + logger.error(f"SignalR negotiate failed: {response.status_code}") + return None + except Exception as e: + logger.error(f"SignalR negotiate error: {e}") + return None + + async def _listen_loop(self): + """Main WebSocket listen loop with auto-reconnection.""" + try: + import websockets + except ImportError: + logger.error("SignalR: websockets package not installed") + return + + while self._running: + try: + token = await self._negotiate() + if not token: + logger.warning("SignalR: Failed to negotiate, retrying in 10s...") + await asyncio.sleep(10) + continue + + encoded_token = urllib.parse.quote(token, safe='') + conn_data = urllib.parse.quote(json.dumps([{"name": "default"}]), safe='') + ws_url = ( + f"{self._get_ws_base()}/signalr/connect" + f"?transport=webSockets" + f"&clientProtocol=1.5" + f"&connectionToken={encoded_token}" + f"&connectionData={conn_data}" + ) + + logger.info("SignalR: Connecting to WebSocket...") + async with websockets.connect(ws_url) as ws: + logger.info("SignalR: Connected, listening for broadcasts") + + while self._running: + try: + msg = await asyncio.wait_for(ws.recv(), timeout=30) + if msg: + data = json.loads(msg) + messages = data.get("M", []) + for m in messages: + await self._handle_message(m) + except asyncio.TimeoutError: + # Normal - no messages received, keep listening + continue + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"SignalR: WebSocket recv error: {e}") + break + + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"SignalR: Connection failed: {e}, reconnecting in 5s...") + await asyncio.sleep(5) + + async def _handle_message(self, message: dict): + """Handle a SignalR broadcast message.""" + args = message.get("A", []) + for arg in args: + if "" in arg: + try: + ticket_id_str = arg.split("")[1] + ticket_id = int(ticket_id_str) + logger.info(f"SignalR: TICKET_REFRESH for ticket {ticket_id}") + await self._process_ticket_refresh(ticket_id) + except (ValueError, IndexError) as e: + logger.warning(f"SignalR: Failed to parse TICKET_REFRESH: {arg} - {e}") + + async def _process_ticket_refresh(self, sambapos_ticket_id: int): + """Handle a TICKET_REFRESH broadcast. + + Fetches the specific ticket by ID from SambaPOS GraphQL and + persists it as a KDS entry. This captures instantly-closed tickets + (free breakfast, bar tabs) that never appear in getTickets(isClosed: false). + + Always publishes an SSE event afterwards so the frontend refreshes. + """ + # Fetch and persist the ticket directly + try: + await self._fetch_and_persist_ticket(sambapos_ticket_id) + except Exception as e: + logger.warning(f"SignalR: Failed to fetch/persist ticket {sambapos_ticket_id}: {e}") + + # Always notify SSE subscribers for instant frontend refresh + await kds_event_bus.publish({ + "type": "ticket_refresh", + "sambapos_ticket_id": sambapos_ticket_id, + "timestamp": datetime.utcnow().isoformat(), + }) + logger.info(f"SignalR: Published SSE event for ticket {sambapos_ticket_id}") + + async def _fetch_and_persist_ticket(self, sambapos_ticket_id: int): + """Fetch a specific ticket from SambaPOS and create/update a KDS entry.""" + from services.kds_graphql import SambaPOSGraphQLClient, transform_ticket_for_kds + from api.kds import get_or_create_kds_ticket + from database import AsyncSessionLocal + + client = SambaPOSGraphQLClient( + server_url=self.base_url, + username=self.graphql_username, + password=self.graphql_password, + client_id=self.graphql_client_id, + ) + + result = await client.get_ticket_by_id(sambapos_ticket_id) + + if "error" in result: + logger.warning(f"SignalR: get_ticket_by_id({sambapos_ticket_id}) error: {result['error']}") + return + + ticket_data = result.get("data", {}).get("getTicket") + if not ticket_data: + logger.debug(f"SignalR: get_ticket_by_id({sambapos_ticket_id}) returned no data") + return + + transformed = transform_ticket_for_kds(ticket_data) + if not transformed: + logger.debug(f"SignalR: Ticket {sambapos_ticket_id} has no kitchen orders, skipping") + return + + async with AsyncSessionLocal() as db: + kds_ticket = await get_or_create_kds_ticket( + db, self.kitchen_id, transformed, self.course_order + ) + logger.info( + f"SignalR: Persisted ticket {sambapos_ticket_id} " + f"(KDS #{kds_ticket.id}, number={kds_ticket.ticket_number})" + ) + + def start(self): + """Start the listener as a background asyncio task.""" + self._running = True + self._task = asyncio.create_task(self._listen_loop()) + logger.info("SignalR: Listener started") + + def stop(self): + """Stop the listener.""" + self._running = False + if self._task: + self._task.cancel() + logger.info("SignalR: Listener stopped") + + +# Global listener instance +_listener: Optional[SignalRListener] = None + + +async def start_signalr_listener(): + """Start the global SignalR listener using KDS settings from DB.""" + global _listener + + from database import AsyncSessionLocal + from sqlalchemy import select + from models.settings import KitchenSettings + + try: + async with AsyncSessionLocal() as db: + # Get first kitchen with KDS GraphQL configured + result = await db.execute( + select(KitchenSettings).where( + KitchenSettings.kds_graphql_url.isnot(None) + ) + ) + settings = result.scalar_one_or_none() + + if not settings: + logger.info("SignalR: No KDS GraphQL URL configured, listener not started") + return + + if not all([settings.kds_graphql_url, settings.kds_graphql_username, + settings.kds_graphql_password, settings.kds_graphql_client_id]): + logger.info("SignalR: KDS GraphQL credentials incomplete, listener not started") + return + + course_order = settings.kds_course_order or ["Starters", "Mains", "Desserts"] + + _listener = SignalRListener( + base_url=settings.kds_graphql_url, + graphql_username=settings.kds_graphql_username, + graphql_password=settings.kds_graphql_password, + graphql_client_id=settings.kds_graphql_client_id, + kitchen_id=settings.kitchen_id, + course_order=course_order, + ) + _listener.start() + + except Exception as e: + logger.error(f"SignalR: Failed to start listener: {e}") + + +async def stop_signalr_listener(): + """Stop the global SignalR listener.""" + global _listener + if _listener: + _listener.stop() + _listener = None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..939af83 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + backend: + build: ./backend + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - APP_SLUG=kds + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 60s + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME:-Hotel} + security_opt: + - apparmor=unconfined + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2e615d9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder + +ARG VITE_HOTEL_NAME=Hotel +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME + +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html/kds +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6127376 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Kitchen Display + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..58a6593 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + + # Block internal inter-app endpoints from the public internet + location /kds/api/internal/ { + return 403; + } + + # Central auth proxy (must come before the general /api/ block) + location /kds/api/auth/ { + proxy_pass http://10.10.10.101:3001/api/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # KDS backend API (preserves /api/ prefix: /kds/api/kds/tickets → backend:8000/api/kds/tickets) + location /kds/api/ { + proxy_pass http://backend:8000/api/; + 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 Cookie $http_cookie; + # SSE requires these headers + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } + + # Health check + location /kds/health { + proxy_pass http://backend:8000/health; + } + + # SPA fallback — all other /kds/* paths serve the React app + location /kds/ { + try_files $uri $uri/ /kds/index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..997a2e2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "kds-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.17.9", + "lucide-react": "^0.395.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.1" + }, + "devDependencies": { + "@types/react": "^18.2.47", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2304af3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,18 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import AuthGate from './components/AuthGate' + +// Re-export for any archive imports that use `import { useAuth } from '../App'` +export { useAuth } from './components/AuthGate' + +import KDSApp from './pages/KDSApp' + +export default function App() { + return ( + + + } /> + } /> + + + ) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..3b35566 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import type { User } from '../types' + +interface AuthCtx { + user: User + token: string + restrictedPages: string[] + login: (t: string) => void + logout: () => void +} +const Ctx = createContext(null) + +export function useAuth() { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useAuth outside AuthGate') + return ctx +} + +export default function AuthGate({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [checking, setChecking] = useState(true) + + useEffect(() => { + fetch('/kds/api/auth/verify?app=kds', { credentials: 'include' }) + .then((r) => { + if (!r.ok) throw new Error('unauth') + return r.json() + }) + .then((data) => + setUser({ + email: data.email || data.sub || '', + name: data.name || data.display_name || '', + is_admin: data.is_admin ?? false, + caps: data.caps ?? [], + }) + ) + .catch(() => { + ;(window.top ?? window).location.href = '/login' + }) + .finally(() => setChecking(false)) + }, []) + + if (checking) { + return ( +
+
+
+ ) + } + + if (!user) return null + + const ctx: AuthCtx = { + user, + token: '__session__', + restrictedPages: [], + login: () => {}, + logout: () => { (window.top ?? window).location.href = '/login' }, + } + + return {children} +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7607910 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,89 @@ +:root { + /* Stack palette */ + --navy-dark: #1a1a2e; + --navy-mid: #16213e; + --navy-light: #0f3460; + --gold: #c9a84c; + --text-primary: #e8e8e8; + --text-muted: #9ca3af; + --bg-content: #f4f5f7; + --border: rgba(255, 255, 255, 0.1); + + /* KDS-specific — dark board theme */ + --kds-bg: #0d0d1a; + --kds-card: #1a1a2e; + --kds-border: rgba(255, 255, 255, 0.08); + --kds-green: #22c55e; + --kds-amber: #f59e0b; + --kds-red: #ef4444; + --kds-blue: #3b82f6; + --kds-sent: #6b7280; + + /* App primary — teal (shared with kitchen for recipe images etc.) */ + --app-primary: #0d9488; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body, #root { + height: 100%; + overflow: hidden; +} + +body { + background: var(--kds-bg); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +/* KDS is fullscreen — no sidebar layout needed */ + +/* Spinner */ +.spinner { + border: 3px solid rgba(255,255,255,0.15); + border-top-color: var(--gold); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Shared badge style used by KDS status indicators */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} +.badge-green { background: rgba(34,197,94,0.15); color: var(--kds-green); } +.badge-amber { background: rgba(245,158,11,0.15); color: var(--kds-amber); } +.badge-red { background: rgba(239,68,68,0.15); color: var(--kds-red); } +.badge-blue { background: rgba(59,130,246,0.15); color: var(--kds-blue); } +.badge-grey { background: rgba(107,114,128,0.15);color: var(--kds-sent); } + +/* Button */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.btn:hover { opacity: 0.85; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } +.btn-primary { background: var(--gold); color: #000; } +.btn-ghost { background: transparent; color: var(--text-primary); border: 1px solid var(--border); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ef8d05e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const qc = new QueryClient({ + defaultOptions: { queries: { staleTime: 10 * 1000, retry: 1 } }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/KDS.tsx b/frontend/src/pages/KDS.tsx new file mode 100644 index 0000000..486d517 --- /dev/null +++ b/frontend/src/pages/KDS.tsx @@ -0,0 +1,1629 @@ +import { useState, useEffect, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../App' + +interface KDSOrderTag { + tag: string + tagName: string + quantity?: number +} + +interface KDSOrder { + id: number + uid: string | null + name: string + portion: string | null + quantity: number + price: number | null + kitchen_course: string | null + status: string + kitchen_print: string | null + is_voided?: boolean + voided_at?: string | null + is_sent?: boolean + is_addition?: boolean + tags?: KDSOrderTag[] +} + +interface CourseState { + status: 'pending' | 'away' | 'sent' | 'cleared' + called_away_at: string | null + sent_at: string | null + sent_by: string | null + cleared_at?: string | null +} + +interface KDSTicket { + id: number + sambapos_ticket_id: number + ticket_number: string + table_name: string | null + covers: number | null + received_at: string + time_elapsed_seconds: number + orders: KDSOrder[] + orders_by_course: Record + course_states: Record + is_bumped: boolean +} + +interface CourseConfig { + name: string + prep_green: number + prep_amber: number + prep_red: number + away_green: number + away_amber: number + away_red: number +} + +interface KDSSettings { + kds_enabled: boolean + kds_graphql_url: string | null + kds_graphql_username: string | null + kds_graphql_client_id: string | null + kds_poll_interval_seconds: number + kds_timer_green_seconds: number + kds_timer_amber_seconds: number + kds_timer_red_seconds: number + kds_away_timer_green_seconds: number + kds_away_timer_amber_seconds: number + kds_away_timer_red_seconds: number + kds_course_order: CourseConfig[] + kds_show_completed_for_seconds: number + kds_bookings_refresh_seconds: number +} + +interface KDSBookingItem { + booking_time: string + people: number + status: string + table_name: string | null + seating_area: string | null + is_hotel_guest: boolean | null + is_dbb: boolean | null + is_package: boolean | null + is_flagged: boolean + flag_reasons: string | null + allergies: string | null + kds_stage: string | null +} + +interface KDSBookingsData { + period_name: string | null + total_bookings: number + total_covers: number + flag_icon_mapping: Record | null + bookings: KDSBookingItem[] +} + +const defaultCourseConfig: CourseConfig[] = [ + { name: 'Starters', prep_green: 300, prep_amber: 600, prep_red: 900, away_green: 600, away_amber: 900, away_red: 1200 }, + { name: 'Mains', prep_green: 300, prep_amber: 600, prep_red: 900, away_green: 600, away_amber: 900, away_red: 1200 }, + { name: 'Desserts', prep_green: 300, prep_amber: 600, prep_red: 900, away_green: 600, away_amber: 900, away_red: 1200 }, +] + +const defaultSettings: KDSSettings = { + kds_enabled: false, + kds_graphql_url: null, + kds_graphql_username: null, + kds_graphql_client_id: null, + kds_poll_interval_seconds: 6000, + kds_timer_green_seconds: 300, + kds_timer_amber_seconds: 600, + kds_timer_red_seconds: 900, + kds_away_timer_green_seconds: 600, + kds_away_timer_amber_seconds: 900, + kds_away_timer_red_seconds: 1200, + kds_course_order: defaultCourseConfig, + kds_show_completed_for_seconds: 30, + kds_bookings_refresh_seconds: 60, +} + +// SVG Icon components +const RefreshIcon = () => ( + + + + + +) + +const FullscreenIcon = () => ( + + + + + + +) + +const ExitFullscreenIcon = () => ( + + + + + + +) + +const ExitIcon = () => ( + + + + + +) + +// Simple plate icon (circle with inner ring) +const PlateIcon = () => ( + + + + +) + +export default function KDS() { + const { token } = useAuth() + const queryClient = useQueryClient() + const navigate = useNavigate() + const [isFullscreen, setIsFullscreen] = useState(false) + const [selectedTicket, setSelectedTicket] = useState(null) + const [showPending, setShowPending] = useState(false) + const [showBookings, setShowBookings] = useState(false) + const [recipeOverlay, setRecipeOverlay] = useState(null) + const [recipeLoading, setRecipeLoading] = useState(false) + // Tick counter for live timer updates + const [, setTick] = useState(0) + + // Fetch settings + const { data: settings = defaultSettings } = useQuery({ + queryKey: ['kds-settings'], + queryFn: async () => { + const res = await fetch('/kds/api/kds/settings', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to fetch settings') + return res.json() + }, + enabled: !!token, + }) + + // Fetch tickets + const { data: tickets = [], isLoading, error, refetch } = useQuery({ + queryKey: ['kds-tickets'], + queryFn: async () => { + const res = await fetch('/kds/api/kds/tickets', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to fetch tickets') + return res.json() + }, + enabled: !!token, + refetchInterval: settings.kds_poll_interval_seconds * 1000, + }) + + // Fetch bookings for current service period (Resos) + const { data: bookingsData } = useQuery({ + queryKey: ['kds-bookings'], + queryFn: async () => { + const res = await fetch('/kds/api/kds/bookings', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to fetch bookings') + return res.json() + }, + enabled: !!token, + refetchInterval: (settings.kds_bookings_refresh_seconds || 60) * 1000, + }) + + // Tick every second for live timers + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 1000) + return () => clearInterval(interval) + }, []) + + // Subscribe to SSE for real-time updates from SignalR listener + useEffect(() => { + let es: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let stopped = false + + const connect = () => { + if (stopped) return + es = new EventSource('/kds/api/kds/events') + + es.onmessage = () => { + queryClient.invalidateQueries({ queryKey: ['kds-tickets'] }) + queryClient.invalidateQueries({ queryKey: ['kds-bookings'] }) + } + + es.onerror = () => { + // If the connection was permanently closed (e.g. 502), manually reconnect + if (es && es.readyState === EventSource.CLOSED) { + es.close() + es = null + if (!stopped) { + reconnectTimer = setTimeout(connect, 5000) + } + } + } + } + + connect() + + return () => { + stopped = true + if (reconnectTimer) clearTimeout(reconnectTimer) + if (es) es.close() + } + }, [queryClient]) + + // Fetch recipe link for a KDS order item + const showRecipeLink = useCallback(async (menuItemName: string) => { + if (!token || recipeLoading) return + setRecipeLoading(true) + try { + const res = await fetch(`/api/kds/recipe-link/${encodeURIComponent(menuItemName)}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + const data = await res.json() + if (data) { + setRecipeOverlay(data) + } + } + } catch { /* ignore */ } + setRecipeLoading(false) + }, [token, recipeLoading]) + + // Course AWAY mutation (mark course as called away) + const courseAwayMutation = useMutation({ + mutationFn: async ({ ticketId, courseName }: { ticketId: number; courseName: string }) => { + const res = await fetch('/kds/api/kds/course-away', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ ticket_id: ticketId, course_name: courseName }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail || 'Failed to call away course') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['kds-tickets'] }) + queryClient.invalidateQueries({ queryKey: ['kds-bookings'] }) + }, + }) + + // Course SENT mutation (mark course as food delivered) + const courseSentMutation = useMutation({ + mutationFn: async ({ ticketId, courseName }: { ticketId: number; courseName: string }) => { + const res = await fetch('/kds/api/kds/course-sent', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ ticket_id: ticketId, course_name: courseName }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail || 'Failed to mark course as sent') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['kds-tickets'] }) + queryClient.invalidateQueries({ queryKey: ['kds-bookings'] }) + }, + }) + + // Bump full ticket mutation (complete) + const bumpTicketMutation = useMutation({ + mutationFn: async (ticketId: number) => { + const res = await fetch(`/api/kds/bump-ticket/${ticketId}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to bump ticket') + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['kds-tickets'] }) + queryClient.invalidateQueries({ queryKey: ['kds-bookings'] }) + }, + }) + + // Toggle fullscreen + const toggleFullscreen = useCallback(() => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen() + setIsFullscreen(true) + } else { + document.exitFullscreen() + setIsFullscreen(false) + } + }, []) + + // Listen for fullscreen changes + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) + }, []) + + // Compute elapsed seconds from an ISO timestamp + const getElapsedSeconds = (isoString: string | null): number => { + if (!isoString) return 0 + return Math.floor((Date.now() - new Date(isoString).getTime()) / 1000) + } + + // Look up per-course config, falling back to global settings + const getCourseConfig = (courseName: string): CourseConfig => { + const found = settings.kds_course_order.find((c) => c.name === courseName) + if (found) return found + return { + name: courseName, + prep_green: settings.kds_timer_green_seconds, + prep_amber: settings.kds_timer_amber_seconds, + prep_red: settings.kds_timer_red_seconds, + away_green: settings.kds_away_timer_green_seconds, + away_amber: settings.kds_away_timer_amber_seconds, + away_red: settings.kds_away_timer_red_seconds, + } + } + + // Get prep timer color (course is "away" - waiting to be served) + const getPrepTimerColor = (seconds: number, config: CourseConfig): string => { + if (seconds >= config.prep_red) return '#e94560' + if (seconds >= config.prep_amber) return '#f39c12' + return '#27ae60' + } + + // Get away timer color (course is "sent" - food at table, eating) + const getAwayTimerColor = (seconds: number, config: CourseConfig): string => { + if (seconds >= config.away_red) return '#e94560' + if (seconds >= config.away_amber) return '#f39c12' + return '#8e8ea0' + } + + // Format seconds as MM:SS + const formatTimer = (seconds: number): string => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + // Get courses in order for a ticket + const getOrderedCourses = (ticket: KDSTicket): string[] => { + const ticketCourses = Object.keys(ticket.orders_by_course) + const configNames = settings.kds_course_order.map((c) => c.name) + return configNames.filter((c) => ticketCourses.includes(c)) + .concat(ticketCourses.filter((c) => !configNames.includes(c))) + } + + // Check if all courses are sent or cleared + const allCoursesSent = (ticket: KDSTicket): boolean => { + const courses = getOrderedCourses(ticket) + return courses.every((c) => { + const s = ticket.course_states[c]?.status + return s === 'sent' || s === 'cleared' + }) + } + + // Get the "active" course timer color for the ticket header border + const getTicketHeaderColor = (ticket: KDSTicket): string => { + const courses = getOrderedCourses(ticket) + // Find the first course that's "away" - use its prep timer with per-course config + for (const c of courses) { + const state = ticket.course_states[c] + if (state?.status === 'away' && state.called_away_at) { + return getPrepTimerColor(getElapsedSeconds(state.called_away_at), getCourseConfig(c)) + } + } + // If all sent, use first course config as fallback + const firstConfig = courses.length > 0 ? getCourseConfig(courses[0]) : getCourseConfig('default') + return getPrepTimerColor(getElapsedSeconds(ticket.received_at), firstConfig) + } + + // Get table state: what's currently "on the table" + // Empty when no course sent yet, or when course has been cleared + // Shows plate + course letter when food is at the table + const getTableState = (ticket: KDSTicket): { isEmpty: boolean; courseLabel: string | null } => { + const courses = getOrderedCourses(ticket) + // Walk backwards to find the latest "sent" course (not cleared) + for (let i = courses.length - 1; i >= 0; i--) { + const state = ticket.course_states[courses[i]] + if (state?.status === 'sent') { + return { isEmpty: false, courseLabel: courses[i].charAt(0).toUpperCase() } + } + } + return { isEmpty: true, courseLabel: null } + } + + // Check if previous course is sent or cleared (for enabling AWAY button) + const isPreviousCourseSent = (ticket: KDSTicket, courseName: string): boolean => { + const courses = getOrderedCourses(ticket) + const idx = courses.indexOf(courseName) + if (idx <= 0) return true // First course or not found + const prevCourse = courses[idx - 1] + const s = ticket.course_states[prevCourse]?.status + return s === 'sent' || s === 'cleared' + } + + // Get consolidated pending orders (not yet sent) across all tickets, grouped by course + const getPendingOrdersByCourse = (): { courseName: string; items: { name: string; portion: string | null; qty: number }[] }[] => { + const courseMap: Record> = {} + for (const ticket of tickets) { + const courses = getOrderedCourses(ticket) + for (const courseName of courses) { + const status = ticket.course_states[courseName]?.status || 'pending' + if (status === 'sent' || status === 'cleared') continue + // Use annotated orders so we can skip individually-sent orders + const orders = ticket.orders.filter(o => (o.kitchen_course || 'Uncategorized') === courseName) + for (const order of orders) { + if (order.is_voided) continue + if (order.is_sent) continue // Already sent — not pending + if (!courseMap[courseName]) courseMap[courseName] = {} + const key = `${order.name}||${order.portion || ''}` + if (!courseMap[courseName][key]) { + courseMap[courseName][key] = { name: order.name, portion: order.portion, qty: 0 } + } + courseMap[courseName][key].qty += order.quantity + } + } + } + const configNames = settings.kds_course_order.map((c) => c.name) + const courseNames = Object.keys(courseMap) + const ordered = configNames.filter((c) => courseNames.includes(c)) + .concat(courseNames.filter((c) => !configNames.includes(c))) + return ordered.map((courseName) => ({ + courseName, + items: Object.values(courseMap[courseName]).sort((a, b) => b.qty - a.qty), + })) + } + + // Format ISO timestamp as HH:MM + const formatTime = (isoString: string | null): string => { + if (!isoString) return '' + const date = new Date(isoString) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + } + + return ( +
+ {/* Main content area */} +
+ {/* Status bar */} + {error && ( +
+ Connection error - showing cached data. Check SambaPOS connection settings. +
+ )} + + {/* Tickets grid */} + {isLoading && tickets.length === 0 ? ( +
Loading tickets...
+ ) : tickets.length === 0 ? ( +
+

No Active Tickets

+

Tickets will appear here when orders are submitted in SambaPOS

+
+ ) : ( + <> +
+ {[...tickets].sort((a, b) => new Date(a.received_at).getTime() - new Date(b.received_at).getTime()).map((ticket) => { + const orderedCourses = getOrderedCourses(ticket) + const headerColor = getTicketHeaderColor(ticket) + const isAllSent = allCoursesSent(ticket) + + const tableState = getTableState(ticket) + + return ( +
+ {/* Ticket header - click to open detail modal */} +
setSelectedTicket(ticket)} + > + {ticket.table_name || `#${ticket.ticket_number}`} + {ticket.covers && {ticket.covers} pax} + {/* Table state indicator - centered */} +
+ {!tableState.isEmpty && ( + <> + + {tableState.courseLabel} + + )} +
+ {formatTime(ticket.received_at)} +
+ + {/* Courses */} +
+ {orderedCourses.map((courseName, courseIdx) => { + // Use annotated orders (with is_sent/is_addition) grouped by course + const orders = ticket.orders.filter(o => (o.kitchen_course || 'Uncategorized') === courseName) + const courseState = ticket.course_states[courseName] + const status = courseState?.status || 'pending' + const isCleared = status === 'cleared' + const isSent = status === 'sent' + const isAway = status === 'away' + const isPending = status === 'pending' + const canCallAway = isPending && isPreviousCourseSent(ticket, courseName) + + // Check if next course has moved on (away or sent) - hide sent timer if so + const nextCourse = courseIdx < orderedCourses.length - 1 ? orderedCourses[courseIdx + 1] : null + const nextCourseStatus = nextCourse ? (ticket.course_states[nextCourse]?.status || 'pending') : 'pending' + const nextCourseMovedOn = nextCourseStatus === 'away' || nextCourseStatus === 'sent' || nextCourseStatus === 'cleared' + + // Compute timers + const awayElapsed = isAway && courseState?.called_away_at + ? getElapsedSeconds(courseState.called_away_at) : 0 + const sentElapsed = isSent && courseState?.sent_at + ? getElapsedSeconds(courseState.sent_at) : 0 + + // Per-course timer config and colors + const courseConfig = getCourseConfig(courseName) + const awayTimerColor = isAway ? getPrepTimerColor(awayElapsed, courseConfig) : '#4a4a6a' + const sentTimerColor = isSent ? getAwayTimerColor(sentElapsed, courseConfig) : '#4a4a6a' + + // Hide cleared courses and sent courses whose next has moved on + if (isCleared) return null + if (isSent && nextCourseMovedOn) return null + + return ( +
+
+ {courseName} + + {/* AWAY state: show timer + SENT button */} + {isAway && ( + <> + + {formatTimer(awayElapsed)} + + + + )} + + {/* PENDING state: only show AWAY button when enabled */} + {isPending && canCallAway && ( + + )} + + {/* SENT state: show "SENT MM:SS" timer, but hide once next course moves on */} + {isSent && !nextCourseMovedOn && ( + + SEND {formatTimer(sentElapsed)} + + )} +
+
+ {orders.filter(order => { + const voided = order.is_voided || ['void', 'cancelled', 'canceled'].includes((order.status || '').toLowerCase()) + return voided ? (isPending || isAway) : true + }).map((order) => { + const voided = order.is_voided || ['void', 'cancelled', 'canceled'].includes((order.status || '').toLowerCase()) + return ( +
+
+ {order.quantity}x + { e.stopPropagation(); showRecipeLink(order.name) }} + title="View recipe" + >{order.name} + {order.portion && order.portion !== 'Normal' && ( + ({order.portion}) + )} + {voided && ( + + *VOID* + + )} + {!voided && order.is_addition && ( + + *NEW* + + )} +
+ {!voided && order.tags && order.tags.length > 0 && ( +
+ {order.tags.map((t, i) => ( +
+ {t.quantity && t.quantity > 1 ? `${Math.round(t.quantity)}x ` : ''}{t.tag} +
+ ))} +
+ )} +
+ ) + })} +
+
+ ) + })} +
+ + {/* Complete ticket button - only shows when all courses sent */} + {isAllSent && ( + + )} +
+ ) + })} +
+ {/* Right-side overlay panels container - stacked at bottom right */} + {(showBookings || showPending) && ( +
+ {/* Bookings overlay panel */} + {showBookings && (() => { + const data = bookingsData + const iconMapping = data?.flag_icon_mapping || {} + const defaultIcons: Record = { + 'allergies': '🦀', + 'large_group': '👥', + 'note_keyword_birthday': '🎂', + 'note_keyword_anniversary': '💍', + } + const getIcon = (flag: string): string => { + if (iconMapping[flag]) return iconMapping[flag] + if (flag.startsWith('note_keyword_')) { + const kw = flag.replace('note_keyword_', '') + if (iconMapping[kw]) return iconMapping[kw] + } + return defaultIcons[flag] || '⚠️' + } + const statusColor = (s: string, kdsStage: string | null): string => { + if (kdsStage) return '#2ecc71' // green for KDS-tracked + const sl = s.toLowerCase() + if (sl === 'left') return '#9b59b6' // purple + if (sl === 'seated') return '#e74c3c' // red + if (sl === 'arrived') return '#e91e63' // pink + if (sl === 'confirmed' || sl === 'approved' || sl === 'booked') return '#3498db' // blue + return '#666' + } + const fmtTime = (t: string): string => { + if (!t) return '' + // Handle ISO datetime or HH:MM string + if (t.includes('T')) { + const d = new Date(t) + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + } + return t.slice(0, 5) + } + + if (!data || data.bookings.length === 0) return ( +
+
+ {data?.period_name || 'BOOKINGS'} +
+
No bookings
+
+ ) + return ( +
+
+ {data.period_name || 'BOOKINGS'} + + {data.total_bookings} bookings · {data.total_covers} covers + +
+ {data.bookings.map((b, i) => ( +
+
+ {fmtTime(b.booking_time)} + {b.table_name && {b.table_name}} + {!b.table_name && b.seating_area && {b.seating_area}} + {b.people}pax +
+ {b.kds_stage && ( +
{b.kds_stage}
+ )} + {(b.is_hotel_guest || b.is_dbb || b.is_package || b.is_flagged) && ( +
+ {b.is_hotel_guest && 🛏️} + {(b.is_dbb || b.is_package) && 🍽️} + {b.is_flagged && b.flag_reasons && b.flag_reasons.split(',').map((f, fi) => ( + {getIcon(f.trim())} + ))} +
+ )} +
+ ))} +
+ ) + })()} + {/* Pending orders panel */} + {showPending && (() => { + const pendingCourses = getPendingOrdersByCourse() + if (pendingCourses.length === 0) return ( +
+
PENDING
+
No pending orders
+
+ ) + return ( +
+
PENDING
+ {pendingCourses.map((course) => ( +
+
{course.courseName}
+ {course.items.map((item, i) => ( +
+ {item.qty}x + {item.name} + {item.portion && item.portion !== 'Normal' && ( + ({item.portion}) + )} +
+ ))} +
+ ))} +
+ ) + })()} +
+ )} + + )} +
+ + {/* Ticket detail modal */} + {selectedTicket && (() => { + const ticket = selectedTicket + const orderedCourses = getOrderedCourses(ticket) + return ( +
setSelectedTicket(null)}> +
e.stopPropagation()}> + {/* Modal header */} +
+
+ + {ticket.table_name || `#${ticket.ticket_number}`} + + + Ticket #{ticket.ticket_number} + + {ticket.covers && ( + {ticket.covers} pax + )} + + Arrived {formatTime(ticket.received_at)} + +
+ +
+ + {/* All courses with full detail */} +
+ {orderedCourses.map((courseName) => { + // Use annotated orders (with is_sent/is_addition) grouped by course + const orders = ticket.orders.filter(o => (o.kitchen_course || 'Uncategorized') === courseName) + const courseState = ticket.course_states[courseName] + const status = courseState?.status || 'pending' + + const statusColor = status === 'cleared' ? '#3498db' + : status === 'sent' ? '#27ae60' + : status === 'away' ? '#e67e22' : '#8e8ea0' + const statusLabel = status.toUpperCase() + + return ( +
+
+ {courseName} + + {statusLabel} + +
+ {/* Course timing info */} +
+ {courseState?.called_away_at && ( + + Away: {formatTime(courseState.called_away_at)} + + )} + {courseState?.sent_at && ( + + Sent: {formatTime(courseState.sent_at)} + + )} + {courseState?.cleared_at && ( + + Cleared: {formatTime(courseState.cleared_at)} + + )} +
+ {/* Orders - show all, voided with strikethrough */} +
+ {orders.map((order) => { + const voided = order.is_voided || ['void', 'cancelled', 'canceled'].includes((order.status || '').toLowerCase()) + return ( +
+
+ {order.quantity}x + {order.name} + {order.portion && order.portion !== 'Normal' && ( + ({order.portion}) + )} + {voided && VOID} +
+ {!voided && order.tags && order.tags.length > 0 && ( +
+ {order.tags.map((t, i) => ( +
+ {t.quantity && t.quantity > 1 ? `${Math.round(t.quantity)}x ` : ''}{t.tag} +
+ ))} +
+ )} +
+ ) + })} +
+
+ ) + })} +
+
+
+ ) + })()} + + {/* Right toolbar */} +
+ {/* Ticket count badge */} +
+ {tickets.length} +
+ +
+ + {/* Refresh */} + + + {/* Fullscreen toggle */} + + + {/* Pending orders toggle */} + + + {/* Bookings toggle */} + + +
+ + {/* Exit to dashboard */} + +
+ + {/* Recipe Overlay */} + {recipeOverlay && ( +
setRecipeOverlay(null)} + > +
e.stopPropagation()} + > +
+

{recipeOverlay.name}

+ +
+ + {recipeOverlay.description && ( +

{recipeOverlay.description}

+ )} + + {recipeOverlay.flags && recipeOverlay.flags.length > 0 && ( +
+ {recipeOverlay.flags.map((f: any, i: number) => ( + + {f.icon || ''} {f.code || f.name} + + ))} +
+ )} + + {recipeOverlay.plating_image && ( +
+ Plating +
+ )} + + {recipeOverlay.ingredients && recipeOverlay.ingredients.length > 0 && ( +
+

Ingredients

+ {recipeOverlay.ingredients.map((ing: any, i: number) => ( +
+ {ing.quantity} {ing.unit} {ing.name} + {ing.notes && — {ing.notes}} +
+ ))} +
+ )} + + {recipeOverlay.steps && recipeOverlay.steps.length > 0 && ( +
+

Method

+ {recipeOverlay.steps.map((s: any) => ( +
+ {s.step_number}. {s.instruction} +
+ ))} +
+ )} +
+
+ )} +
+ ) +} + +const styles: Record = { + container: { + display: 'flex', + height: '100%', + background: '#1a1a2e', + color: 'white', + overflow: 'hidden', + }, + mainArea: { + flex: 1, + overflow: 'auto', + padding: '0.5rem', + }, + // Right toolbar + toolbar: { + width: '52px', + background: '#2d2d44', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '0.75rem 0', + gap: '0.5rem', + borderLeft: '1px solid #3d3d5c', + flexShrink: 0, + }, + toolbarButton: { + width: '40px', + height: '40px', + background: 'transparent', + border: '1px solid #4a4a6a', + color: '#ccc', + borderRadius: '6px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'background 0.15s, color 0.15s', + }, + exitButton: { + borderColor: '#e94560', + color: '#e94560', + }, + ticketBadge: { + width: '36px', + height: '36px', + background: '#4a4a6a', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '0.9rem', + fontWeight: 'bold', + color: '#fff', + }, + toolbarDivider: { + width: '30px', + height: '1px', + background: '#4a4a6a', + }, + // Error / loading / empty + errorBar: { + background: '#e94560', + color: 'white', + padding: '0.5rem 1rem', + borderRadius: '4px', + marginBottom: '1rem', + textAlign: 'center', + }, + loading: { + textAlign: 'center', + padding: '3rem', + fontSize: '1.2rem', + color: '#aaa', + }, + noTickets: { + textAlign: 'center', + padding: '5rem 2rem', + color: '#aaa', + }, + // Tickets grid + ticketsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', + gap: '0.5rem', + alignItems: 'start', + }, + ticketCard: { + background: '#2d2d44', + borderRadius: '6px', + overflow: 'hidden', + }, + ticketHeader: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.4rem 0.5rem', + background: '#3d3d5c', + borderTop: '3px solid #27ae60', + position: 'relative' as const, + }, + tableNumber: { + fontSize: '0.85rem', + fontWeight: 'bold', + whiteSpace: 'nowrap', + }, + covers: { + fontSize: '0.75rem', + color: '#aaa', + whiteSpace: 'nowrap', + }, + tableStateBox: { + width: '28px', + height: '20px', + border: '1px dashed #666', + borderRadius: '3px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '1px', + flexShrink: 0, + position: 'absolute' as const, + left: '50%', + transform: 'translateX(-50%)', + }, + tableStateBoxFilled: { + border: '1px solid #8e8ea0', + background: 'rgba(255,255,255,0.08)', + }, + tableStateLetter: { + fontSize: '0.6rem', + fontWeight: 'bold', + color: '#fff', + lineHeight: 1, + }, + timePlaced: { + fontSize: '0.75rem', + color: '#ccc', + marginLeft: 'auto', + fontFamily: 'monospace', + whiteSpace: 'nowrap', + }, + coursesContainer: { + padding: '0.4rem 0.5rem', + }, + courseSection: { + marginBottom: '0.4rem', + }, + courseHeader: { + display: 'flex', + alignItems: 'center', + gap: '0.3rem', + marginBottom: '0.25rem', + paddingBottom: '0.25rem', + borderBottom: '1px solid #4a4a6a', + }, + courseName: { + fontWeight: 'bold', + textTransform: 'uppercase', + fontSize: '0.7rem', + color: '#aaa', + }, + courseTimer: { + fontFamily: 'monospace', + fontSize: '0.7rem', + fontWeight: 'bold', + marginLeft: 'auto', + }, + sentButton: { + background: '#3498db', + border: 'none', + color: 'white', + padding: '0.15rem 0.5rem', + borderRadius: '3px', + cursor: 'pointer', + fontWeight: 'bold', + fontSize: '0.65rem', + whiteSpace: 'nowrap', + flexShrink: 0, + }, + awayButton: { + background: '#e67e22', + border: 'none', + color: 'white', + padding: '0.15rem 0.5rem', + borderRadius: '3px', + cursor: 'pointer', + fontWeight: 'bold', + fontSize: '0.65rem', + marginLeft: 'auto', + whiteSpace: 'nowrap', + flexShrink: 0, + }, + ordersList: { + display: 'flex', + flexDirection: 'column', + gap: '0.15rem', + }, + orderItemWrap: { + display: 'flex', + flexDirection: 'column', + }, + orderItem: { + display: 'flex', + gap: '0.3rem', + fontSize: '0.78rem', + fontWeight: 'bold', + }, + orderTags: { + paddingLeft: '1.8rem', + display: 'flex', + flexDirection: 'column', + gap: '0.05rem', + }, + orderTag: { + fontSize: '0.7rem', + color: '#e67e22', + fontStyle: 'italic', + }, + orderQty: { + fontWeight: 'bold', + minWidth: '1.5rem', + }, + orderName: { + flex: 1, + }, + orderPortion: { + color: '#aaa', + fontSize: '0.7rem', + }, + bumpAllButton: { + width: '100%', + background: '#27ae60', + border: 'none', + color: 'white', + padding: '0.4rem', + cursor: 'pointer', + fontWeight: 'bold', + fontSize: '0.7rem', + }, + // Side panel container - fixed at bottom right, stacks bookings + pending + sidePanelContainer: { + position: 'fixed' as const, + bottom: '0.5rem', + right: '62px', + display: 'flex', + flexDirection: 'column' as const, + gap: '0.5rem', + maxHeight: '85vh', + zIndex: 10, + }, + // Pending panel - inside side panel container + pendingPanel: { + background: '#2d2d44', + borderRadius: '6px', + border: '1px solid #4a4a6a', + padding: '0.5rem', + maxHeight: '40vh', + overflowY: 'auto' as const, + minWidth: '200px', + maxWidth: '280px', + }, + pendingPanelHeader: { + fontSize: '0.7rem', + fontWeight: 'bold', + color: '#e67e22', + marginBottom: '0.4rem', + paddingBottom: '0.3rem', + borderBottom: '1px solid #4a4a6a', + }, + pendingCourseGroup: { + marginBottom: '0.4rem', + }, + pendingCourseName: { + fontSize: '0.65rem', + fontWeight: 'bold', + color: '#aaa', + textTransform: 'uppercase' as const, + marginBottom: '0.15rem', + }, + pendingItemRow: { + display: 'flex', + gap: '0.25rem', + fontSize: '0.75rem', + color: '#ccc', + paddingLeft: '0.3rem', + }, + pendingItemQty: { + fontWeight: 'bold', + minWidth: '1.5rem', + }, + pendingItemPortion: { + color: '#888', + fontSize: '0.65rem', + }, + // Modal + modalOverlay: { + position: 'fixed' as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0,0,0,0.7)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + }, + modalContent: { + background: '#2d2d44', + borderRadius: '8px', + width: '90%', + maxWidth: '500px', + maxHeight: '85vh', + overflowY: 'auto' as const, + border: '1px solid #4a4a6a', + }, + modalHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0.75rem 1rem', + background: '#3d3d5c', + borderBottom: '1px solid #4a4a6a', + borderRadius: '8px 8px 0 0', + }, + modalTitle: { + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + flexWrap: 'wrap' as const, + }, + modalClose: { + background: 'transparent', + border: '1px solid #4a4a6a', + color: '#ccc', + width: '30px', + height: '30px', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '0.9rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + modalCourses: { + padding: '0.75rem 1rem', + }, + modalCourseSection: { + marginBottom: '0.75rem', + paddingBottom: '0.5rem', + borderBottom: '1px solid #3d3d5c', + }, + modalCourseHeader: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + marginBottom: '0.25rem', + }, + modalCourseName: { + fontWeight: 'bold', + textTransform: 'uppercase' as const, + fontSize: '0.85rem', + color: '#ccc', + }, + modalCourseStatus: { + fontSize: '0.7rem', + fontWeight: 'bold', + marginLeft: 'auto', + }, + modalCourseTimes: { + display: 'flex', + gap: '0.75rem', + marginBottom: '0.4rem', + flexWrap: 'wrap' as const, + }, + modalTimeTag: { + fontSize: '0.75rem', + color: '#8e8ea0', + fontFamily: 'monospace', + }, + modalOrdersList: { + display: 'flex', + flexDirection: 'column' as const, + gap: '0.2rem', + }, + modalOrderItem: { + display: 'flex', + gap: '0.4rem', + fontSize: '0.85rem', + padding: '0.15rem 0', + }, + modalOrderQty: { + fontWeight: 'bold', + minWidth: '2rem', + }, + modalOrderItemWrap: { + display: 'flex', + flexDirection: 'column' as const, + }, + modalOrderTags: { + paddingLeft: '2.4rem', + display: 'flex', + flexDirection: 'column' as const, + gap: '0.05rem', + }, + modalOrderTag: { + fontSize: '0.8rem', + color: '#e67e22', + fontStyle: 'italic', + }, + // Pending toggle button in sidebar + pendingButtonActive: { + borderColor: '#e67e22', + color: '#e67e22', + }, + pendingButtonText: { + writingMode: 'vertical-rl' as const, + transform: 'rotate(180deg)', + fontSize: '0.55rem', + fontWeight: 'bold', + letterSpacing: '0.05em', + lineHeight: 1.3, + whiteSpace: 'pre-line' as const, + textAlign: 'center' as const, + }, + bookingsButtonActive: { + borderColor: '#3498db', + color: '#3498db', + }, + bookingsButtonText: { + writingMode: 'vertical-rl' as const, + transform: 'rotate(180deg)', + fontSize: '0.55rem', + fontWeight: 'bold', + letterSpacing: '0.05em', + lineHeight: 1.3, + textAlign: 'center' as const, + }, + bookingsPanel: { + background: '#2d2d44', + borderRadius: '6px', + border: '1px solid #4a4a6a', + padding: '0.5rem', + maxHeight: '50vh', + overflowY: 'auto' as const, + minWidth: '200px', + maxWidth: '280px', + }, + bookingsPanelHeader: { + fontSize: '0.7rem', + fontWeight: 'bold', + color: '#3498db', + marginBottom: '0.4rem', + paddingBottom: '0.3rem', + borderBottom: '1px solid #4a4a6a', + }, + bookingCard: { + border: '1px solid #4a4a6a', + borderLeftWidth: '3px', + borderLeftColor: '#666', + borderRadius: '4px', + paddingLeft: '0.4rem', + paddingRight: '0.3rem', + paddingTop: '0.25rem', + paddingBottom: '0.25rem', + marginBottom: '0.35rem', + background: 'rgba(255,255,255,0.03)', + }, + bookingLine1: { + display: 'flex', + gap: '0.3rem', + alignItems: 'center', + fontSize: '0.75rem', + color: '#ccc', + }, + bookingTime: { + fontWeight: 'bold', + color: '#fff', + minWidth: '2.5rem', + }, + bookingTable: { + color: '#aaa', + fontSize: '0.7rem', + }, + bookingCovers: { + marginLeft: 'auto', + fontWeight: 'bold', + fontSize: '0.7rem', + color: '#ccc', + }, + bookingIcons: { + display: 'flex', + gap: '0.25rem', + fontSize: '0.75rem', + marginTop: '0.15rem', + paddingLeft: '0.1rem', + filter: 'drop-shadow(0 0 2px rgba(255,255,255,0.8))', + }, + bookingKdsStage: { + fontSize: '0.6rem', + fontWeight: 'bold', + color: '#2ecc71', + letterSpacing: '0.05em', + marginTop: '0.1rem', + }, + recipeOverlayBackdrop: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0,0,0,0.7)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + }, + recipeOverlayPanel: { + background: '#2d2d44', + borderRadius: '12px', + padding: '1.5rem', + maxWidth: '500px', + width: '90%', + maxHeight: '80vh', + overflowY: 'auto' as const, + boxShadow: '0 8px 32px rgba(0,0,0,0.5)', + }, + recipeOverlayHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '0.75rem', + }, + recipeOverlayClose: { + background: 'none', + border: '1px solid #4a4a6a', + color: '#ccc', + fontSize: '1rem', + cursor: 'pointer', + borderRadius: '4px', + width: '32px', + height: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, +} diff --git a/frontend/src/pages/KDSApp.tsx b/frontend/src/pages/KDSApp.tsx new file mode 100644 index 0000000..eff8647 --- /dev/null +++ b/frontend/src/pages/KDSApp.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react' +import KDS from './KDS' + +export default function KDSApp() { + // Lock viewport for touch-screen wall display + useEffect(() => { + const prev = document.title + document.title = 'Kitchen Display' + return () => { document.title = prev } + }, []) + + return +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..445dd1c --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,11 @@ +export interface User { + email: string + name: string + is_admin: boolean + caps: string[] +} + +export function can(user: User | null, cap: string): boolean { + if (!user) return false + return user.is_admin || user.caps.includes(cap) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..cefbab4 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f1224b7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/kds/', +})