Initial KDS scaffold — Phase 2 kitchen port

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:16 +00:00
commit b94585084a
35 changed files with 5195 additions and 0 deletions

59
_copy_from_archive.sh Normal file
View file

@ -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 ..."

19
backend/Dockerfile Normal file
View file

@ -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"]

0
backend/api/__init__.py Normal file
View file

1514
backend/api/kds.py Normal file

File diff suppressed because it is too large Load diff

63
backend/auth.py Normal file
View file

@ -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

38
backend/database.py Normal file
View file

@ -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()

66
backend/main.py Normal file
View file

@ -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"}

View file

View file

@ -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())

View file

@ -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())

View file

@ -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())

View file

@ -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}")

View file

88
backend/models/kds.py Normal file
View file

@ -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

145
backend/models/resos.py Normal file
View file

@ -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")

238
backend/models/settings.py Normal file
View file

@ -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

14
backend/models/user.py Normal file
View file

@ -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

19
backend/requirements.txt Normal file
View file

@ -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

View file

View file

@ -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=<user>
password=<pass>
client_id=<app_key>
"""
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
}

View file

@ -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:<TICKET_REFRESH>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 "<TICKET_REFRESH>" in arg:
try:
ticket_id_str = arg.split("<TICKET_REFRESH>")[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

32
docker-compose.yml Normal file
View file

@ -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

15
frontend/Dockerfile Normal file
View file

@ -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

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<title>Kitchen Display</title>
<link rel="manifest" href="/kds/manifest.json" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
frontend/nginx.conf Normal file
View file

@ -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;
}
}

25
frontend/package.json Normal file
View file

@ -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"
}
}

18
frontend/src/App.tsx Normal file
View file

@ -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 (
<AuthGate>
<Routes>
<Route path="/" element={<KDSApp />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthGate>
)
}

View file

@ -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<AuthCtx | null>(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<User | null>(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 (
<div style={{
position: 'fixed', inset: 0,
background: 'var(--kds-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (!user) return null
const ctx: AuthCtx = {
user,
token: '__session__',
restrictedPages: [],
login: () => {},
logout: () => { (window.top ?? window).location.href = '/login' },
}
return <Ctx.Provider value={ctx}>{children}</Ctx.Provider>
}

89
frontend/src/index.css Normal file
View file

@ -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); }

20
frontend/src/main.tsx Normal file
View file

@ -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(
<React.StrictMode>
<BrowserRouter basename="/kds">
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)

1629
frontend/src/pages/KDS.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -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 <KDS />
}

11
frontend/src/types.ts Normal file
View file

@ -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)
}

19
frontend/tsconfig.json Normal file
View file

@ -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"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/kds/',
})