feat: initial commit — local services (backend + manager dashboard + waiter PWA)

Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

View File

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class BusinessDay(Base):
__tablename__ = "business_days"
id = Column(Integer, primary_key=True, index=True)
status = Column(String, default="open", nullable=False) # 'open' | 'closed'
opened_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
opened_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
notes = Column(Text, nullable=True)
opener = relationship("User", foreign_keys=[opened_by_id])
closer = relationship("User", foreign_keys=[closed_by_id])
shifts = relationship("WaiterShift", back_populates="business_day")

View File

@@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class TableFlagDef(Base):
"""Manager-configurable flag definitions (name, emoji, color)."""
__tablename__ = "table_flag_defs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
emoji = Column(String, nullable=True)
color = Column(String, nullable=True, default="#6b7280") # hex background
text_color = Column(String, nullable=True, default=None) # hex text; None = white
sort_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
assignments = relationship("TableFlagAssignment", back_populates="flag_def", cascade="all, delete-orphan")
class TableFlagAssignment(Base):
"""Active flag on a specific table."""
__tablename__ = "table_flag_assignments"
id = Column(Integer, primary_key=True, index=True)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
flag_id = Column(Integer, ForeignKey("table_flag_defs.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
assigned_by = Column(Integer, ForeignKey("users.id"), nullable=True)
flag_def = relationship("TableFlagDef", back_populates="assignments")
assigned_by_user = relationship("User")

View File

@@ -0,0 +1,48 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class QuickMessageTemplate(Base):
"""Manager-configurable quick message templates."""
__tablename__ = "quick_message_templates"
id = Column(Integer, primary_key=True, index=True)
body = Column(String, nullable=False)
sort_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
class StaffMessage(Base):
"""A message sent from a manager to one or more waiters."""
__tablename__ = "staff_messages"
id = Column(Integer, primary_key=True, index=True)
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
body = Column(Text, nullable=False)
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
target_waiter_ids = Column(Text, nullable=False, default="[]")
table_ids = Column(Text, nullable=False, default="[]")
created_at = Column(DateTime(timezone=True), default=_utcnow)
sender = relationship("User", foreign_keys=[sender_id])
acks = relationship("StaffMessageAck", back_populates="message", cascade="all, delete-orphan")
class StaffMessageAck(Base):
"""Acknowledgement by a specific waiter for a specific message."""
__tablename__ = "staff_message_acks"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("staff_messages.id"), nullable=False)
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
acked_at = Column(DateTime(timezone=True), default=_utcnow)
message = relationship("StaffMessage", back_populates="acks")
waiter = relationship("User")

View File

@@ -0,0 +1,131 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
opened_at = Column(DateTime(timezone=True), default=_utcnow)
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
closed_at = Column(DateTime, nullable=True)
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
notes = Column(Text, nullable=True)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
table = relationship("Table", back_populates="orders")
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
class OrderWaiter(Base):
__tablename__ = "order_waiters"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
order = relationship("Order", back_populates="waiters")
waiter = relationship("User", back_populates="order_assignments")
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False) # price snapshot at time of order
selected_options = Column(Text, nullable=True) # JSON array of option ids
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
notes = Column(Text, nullable=True)
status = Column(String, default="active", nullable=False) # active|paid|cancelled
added_at = Column(DateTime(timezone=True), default=_utcnow)
printed = Column(Boolean, default=False, nullable=False)
# Payment tracking
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
paid_at = Column(DateTime, nullable=True)
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
class PrintLog(Base):
__tablename__ = "print_log"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
printed_at = Column(DateTime(timezone=True), default=_utcnow)
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
success = Column(Boolean, nullable=False)
error_message = Column(Text, nullable=True)
order = relationship("Order", back_populates="print_logs")
printer = relationship("Printer", back_populates="print_logs")
class OrderAuditLog(Base):
"""Immutable append-only audit trail for every action on an order."""
__tablename__ = "order_audit_log"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
event_type = Column(String, nullable=False)
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
amount = Column(Float, nullable=True) # total value for PAYMENT events
payment_method = Column(String, nullable=True)
note = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
# Emergency offline payment fields
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
offline_at = Column(String, nullable=True) # ISO timestamp from client
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
order = relationship("Order", back_populates="audit_logs")
waiter = relationship("User")
@property
def waiter_name(self):
return self.waiter.username if self.waiter else None
class OrderDiscount(Base):
"""Records a discount applied to an order or a specific item."""
__tablename__ = "order_discounts"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
applied_at = Column(DateTime(timezone=True), default=_utcnow)
reason = Column(Text, nullable=True)
order = relationship("Order", back_populates="discounts")
item = relationship("OrderItem")
applied_by_user = relationship("User")

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import relationship
from database import Base
class Printer(Base):
__tablename__ = "printers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
ip_address = Column(String, nullable=False)
port = Column(Integer, default=9100, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
protocol = Column(String, default="escpos_tcp", nullable=False)
products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer")

View File

@@ -0,0 +1,123 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from database import Base
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
color = Column(String, nullable=True)
sort_order = Column(Integer, default=0)
# self-referential: null = top-level, non-null = sub-category of parent
parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
# position of the "General" group (direct products) among sub-categories
general_sort_order = Column(Integer, default=0, nullable=False)
# sub-categories only: if True, the accordion section is expanded by default on the PWA
auto_expanded = Column(Boolean, default=False, nullable=False)
products = relationship("Product", back_populates="category")
subcategories = relationship("Category", back_populates="parent", foreign_keys="Category.parent_id")
parent = relationship("Category", back_populates="subcategories", remote_side="Category.id", foreign_keys="Category.parent_id")
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
base_price = Column(Float, nullable=False)
is_available = Column(Boolean, default=True, nullable=False)
# "active" | "archived" — archived products are kept for order history but hidden from active use
lifecycle_status = Column(String, default="active", nullable=False)
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
image_url = Column(String, nullable=True)
sort_order = Column(Integer, default=0, nullable=False)
category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products")
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
order_items = relationship("OrderItem", back_populates="product")
class ProductOption(Base):
__tablename__ = "product_options"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
allow_multiple = Column(Boolean, default=False, nullable=False)
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
sub_choices = Column(Text, nullable=True)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
product = relationship("Product", back_populates="options")
class ProductQuickOption(Base):
__tablename__ = "product_quick_options"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
price = Column(Float, default=0.0, nullable=False)
allow_multiple = Column(Boolean, default=False, nullable=False)
sort_order = Column(Integer, default=0, nullable=False)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
is_compact = Column(Boolean, default=False, nullable=False)
product = relationship("Product", back_populates="quick_options")
class ProductIngredient(Base):
__tablename__ = "product_ingredients"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
product = relationship("Product", back_populates="ingredients")
class ProductPreferenceSet(Base):
__tablename__ = "product_preference_sets"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
default_choice_id = Column(Integer, nullable=True)
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
# Shared sub-set shown for all choices that don't have disables_subset=True
shared_subset = Column(Text, nullable=True)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
product = relationship("Product", back_populates="preference_sets")
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
class ProductPreferenceChoice(Base):
__tablename__ = "product_preference_choices"
id = Column(Integer, primary_key=True, index=True)
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
# Per-choice inline sub-preference shown only when this choice is selected.
sub_choices = Column(Text, nullable=True)
# When True this choice hides the set-level shared_subset on the PWA.
disables_subset = Column(Boolean, default=False, nullable=False)
set = relationship("ProductPreferenceSet", back_populates="choices")

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class PosSettings(Base):
__tablename__ = "pos_settings"
key = Column(String, primary_key=True)
value = Column(String, nullable=False)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by = relationship("User", foreign_keys=[updated_by_id])

View File

@@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class WaiterShift(Base):
__tablename__ = "waiter_shifts"
id = Column(Integer, primary_key=True, index=True)
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=False)
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
ended_at = Column(DateTime(timezone=True), nullable=True)
starting_cash = Column(Float, nullable=True)
total_collected = Column(Float, nullable=True) # snapshot written at shift end
notes = Column(Text, nullable=True)
waiter = relationship("User", foreign_keys=[waiter_id])
business_day = relationship("BusinessDay", back_populates="shifts")
breaks = relationship("ShiftBreak", back_populates="shift", cascade="all, delete-orphan")
class ShiftBreak(Base):
__tablename__ = "shift_breaks"
id = Column(Integer, primary_key=True, index=True)
shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=False)
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
ended_at = Column(DateTime(timezone=True), nullable=True)
shift = relationship("WaiterShift", back_populates="breaks")

View File

@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
from sqlalchemy.orm import relationship
from database import Base
class TableGroup(Base):
__tablename__ = "table_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True)
prefix = Column(String, nullable=True)
sort_order = Column(Integer, default=0)
color = Column(String, nullable=True)
tables = relationship("Table", back_populates="group")
waiter_zones = relationship("WaiterZone", back_populates="group")
class Table(Base):
__tablename__ = "tables"
id = Column(Integer, primary_key=True, index=True)
number = Column(Integer, nullable=False)
label = Column(String, nullable=True)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
floor_x = Column(Float, nullable=True)
floor_y = Column(Float, nullable=True)
group = relationship("TableGroup", back_populates="tables")
orders = relationship("Order", back_populates="table")

View File

@@ -0,0 +1,70 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, nullable=False, index=True)
pin_hash = Column(String, nullable=False)
password_hash = Column(String, nullable=True)
email = Column(String, nullable=True)
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
is_active = Column(Boolean, default=True, nullable=False)
full_name = Column(String, nullable=True)
nickname = Column(String, nullable=True)
mobile_phone = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
order_items = relationship("OrderItem", foreign_keys="OrderItem.added_by", back_populates="added_by_user")
items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user")
order_assignments = relationship("OrderWaiter", back_populates="waiter")
zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan")
primary_assignments = relationship(
"AssistantAssignment",
foreign_keys="AssistantAssignment.primary_waiter_id",
back_populates="primary_waiter",
)
assistant_assignments = relationship(
"AssistantAssignment",
foreign_keys="AssistantAssignment.assistant_waiter_id",
back_populates="assistant_waiter",
)
class WaiterZone(Base):
"""Maps a waiter to a table group they are allowed to operate in.
If a waiter has NO rows here, they see NOTHING.
A sentinel row with group_id=NULL means 'all zones'."""
__tablename__ = "waiter_zones"
id = Column(Integer, primary_key=True, index=True)
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
waiter = relationship("User", back_populates="zone_assignments")
group = relationship("TableGroup", back_populates="waiter_zones")
class AssistantAssignment(Base):
__tablename__ = "assistant_assignments"
id = Column(Integer, primary_key=True, index=True)
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments")
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")