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:
0
local_backend/schemas/__init__.py
Normal file
0
local_backend/schemas/__init__.py
Normal file
21
local_backend/schemas/auth.py
Normal file
21
local_backend/schemas/auth.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel
|
||||
from schemas.user import UserOut
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
pin: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
user: UserOut
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
full_name: str | None = None
|
||||
username: str | None = None
|
||||
current_password: str | None = None
|
||||
new_password: str | None = None
|
||||
new_pin: str | None = None
|
||||
14
local_backend/schemas/base.py
Normal file
14
local_backend/schemas/base.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from pydantic import PlainSerializer
|
||||
|
||||
# SQLite strips tzinfo on read-back, so naive datetimes from DB are actually UTC.
|
||||
# This serializer appends "Z" so browsers parse them correctly as UTC.
|
||||
UTCDatetime = Annotated[
|
||||
datetime,
|
||||
PlainSerializer(
|
||||
lambda dt: (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat(),
|
||||
return_type=str,
|
||||
when_used="json",
|
||||
),
|
||||
]
|
||||
24
local_backend/schemas/business_day.py
Normal file
24
local_backend/schemas/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class BusinessDayOut(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
opened_at: UTCDatetime
|
||||
opened_by_id: int
|
||||
closed_at: Optional[UTCDatetime] = None
|
||||
closed_by_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OpenBusinessDayRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CloseBusinessDayRequest(BaseModel):
|
||||
force: bool = False
|
||||
notes: Optional[str] = None
|
||||
47
local_backend/schemas/flag.py
Normal file
47
local_backend/schemas/flag.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class FlagDefCreate(BaseModel):
|
||||
name: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = "#6b7280"
|
||||
text_color: Optional[str] = None
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class FlagDefUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FlagDefOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FlagAssignmentOut(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
flag_id: int
|
||||
flag_def: Optional[FlagDefOut] = None
|
||||
assigned_at: datetime
|
||||
assigned_by: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SetTableFlagsRequest(BaseModel):
|
||||
flag_ids: List[int]
|
||||
42
local_backend/schemas/message.py
Normal file
42
local_backend/schemas/message.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class QuickTemplateCreate(BaseModel):
|
||||
body: str
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class QuickTemplateUpdate(BaseModel):
|
||||
body: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class QuickTemplateOut(BaseModel):
|
||||
id: int
|
||||
body: str
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
body: str
|
||||
target_waiter_ids: List[int] # empty = all active waiters
|
||||
table_ids: Optional[List[int]] = []
|
||||
|
||||
|
||||
class StaffMessageOut(BaseModel):
|
||||
id: int
|
||||
sender_id: int
|
||||
sender_name: Optional[str] = None
|
||||
body: str
|
||||
target_waiter_ids: str # raw JSON string — frontend parses
|
||||
table_ids: str
|
||||
created_at: datetime
|
||||
acked_by: List[int] = [] # waiter ids who have acked
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
126
local_backend/schemas/order.py
Normal file
126
local_backend/schemas/order.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class SelectedOptionInput(BaseModel):
|
||||
id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
price_delta: Optional[float] = None
|
||||
extra_cost: Optional[float] = None
|
||||
# type tags: "quick" | "pref" | "pref_sub" | "extra" | "extra_sub"
|
||||
# Omitted by old clients — print code falls back gracefully.
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
selected_options: Optional[List[SelectedOptionInput]] = None
|
||||
removed_ingredients: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddItemsRequest(BaseModel):
|
||||
items: List[OrderItemInput]
|
||||
|
||||
|
||||
class ProductNameOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderItemOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
product_id: int
|
||||
product: Optional[ProductNameOut] = None
|
||||
added_by: int
|
||||
quantity: int
|
||||
unit_price: float
|
||||
selected_options: Optional[str] = None
|
||||
removed_ingredients: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str
|
||||
added_at: UTCDatetime
|
||||
printed: bool
|
||||
paid_by: Optional[int] = None
|
||||
paid_at: Optional[UTCDatetime] = None
|
||||
payment_method: Optional[str] = None
|
||||
paid_in_shift_id: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrintResultOut(BaseModel):
|
||||
printer_name: str
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AddItemsResponse(BaseModel):
|
||||
order: "OrderOut"
|
||||
print_results: List[PrintResultOut]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
table_id: int
|
||||
|
||||
|
||||
class PayItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
||||
|
||||
|
||||
class OfflinePaymentRequest(BaseModel):
|
||||
uuid: str # client-generated UUID, used for duplicate detection
|
||||
item_ids: List[int]
|
||||
payment_method: Optional[str] = None
|
||||
offline_at: Optional[str] = None # ISO timestamp of when payment was taken offline
|
||||
|
||||
|
||||
class AssignWaiterRequest(BaseModel):
|
||||
waiter_id: int
|
||||
|
||||
|
||||
class OrderWaiterOut(BaseModel):
|
||||
waiter_id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditLogOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
event_type: str
|
||||
waiter_id: Optional[int] = None
|
||||
waiter_name: Optional[str] = None # resolved server-side
|
||||
item_ids: Optional[str] = None
|
||||
amount: Optional[float] = None
|
||||
payment_method: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
created_at: UTCDatetime
|
||||
offline_at: Optional[str] = None
|
||||
is_duplicate: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
opened_by: int
|
||||
opened_at: UTCDatetime
|
||||
status: str
|
||||
closed_at: Optional[UTCDatetime] = None
|
||||
closed_by: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
business_day_id: Optional[int] = None
|
||||
items: List[OrderItemOut] = []
|
||||
waiters: List[OrderWaiterOut] = []
|
||||
audit_logs: List[AuditLogOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
31
local_backend/schemas/printer.py
Normal file
31
local_backend/schemas/printer.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
PROTOCOLS = ["escpos_tcp"] # extend later as needed
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str
|
||||
ip_address: str
|
||||
port: int = 9100
|
||||
is_active: bool = True
|
||||
protocol: str = "escpos_tcp"
|
||||
|
||||
|
||||
class PrinterCreate(PrinterBase):
|
||||
pass
|
||||
|
||||
|
||||
class PrinterUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
protocol: Optional[str] = None
|
||||
|
||||
|
||||
class PrinterOut(PrinterBase):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
302
local_backend/schemas/product.py
Normal file
302
local_backend/schemas/product.py
Normal file
@@ -0,0 +1,302 @@
|
||||
import json
|
||||
from pydantic import BaseModel, model_validator, field_validator
|
||||
from typing import Optional, List, Any
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
general_sort_order: int = 0
|
||||
auto_expanded: bool = False
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
parent_id: Optional[int] = None
|
||||
general_sort_order: Optional[int] = None
|
||||
auto_expanded: Optional[bool] = None
|
||||
|
||||
|
||||
class CategoryOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
general_sort_order: int = 0
|
||||
auto_expanded: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategoryReparentRequest(BaseModel):
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class CategoryReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class SubcategoryReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int # position among subcategories within the parent
|
||||
|
||||
|
||||
class ParentGeneralReorderItem(BaseModel):
|
||||
id: int # parent category id
|
||||
general_sort_order: int
|
||||
|
||||
|
||||
# ── Quick Options ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductQuickOptionCreate(BaseModel):
|
||||
name: str
|
||||
price: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
sort_order: int = 0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class ProductQuickOptionOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
name: str
|
||||
price: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
sort_order: int = 0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
is_compact: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class OptionSubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
|
||||
class ProductOptionCreate(ProductOptionBase):
|
||||
sub_choices: List[OptionSubChoice] = []
|
||||
|
||||
|
||||
class ProductOptionOut(ProductOptionBase):
|
||||
id: int
|
||||
product_id: int
|
||||
sub_choices: List[OptionSubChoice] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_option_sub_choices(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'sub_choices'):
|
||||
raw = data.sub_choices
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else []
|
||||
return {
|
||||
'id': data.id,
|
||||
'product_id': data.product_id,
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
|
||||
'sub_choices': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# ── Ingredients ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductIngredientBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
|
||||
class ProductIngredientCreate(ProductIngredientBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductIngredientOut(ProductIngredientBase):
|
||||
id: int
|
||||
product_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Sub-choices (nested under a preference choice) ────────────────────────────
|
||||
|
||||
class SubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
|
||||
|
||||
class SharedSubsetChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class SharedSubset(BaseModel):
|
||||
name: str
|
||||
choices: List[SharedSubsetChoice] = []
|
||||
|
||||
|
||||
# ── Preferences ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PreferenceChoiceCreate(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceOut(BaseModel):
|
||||
id: int
|
||||
set_id: int
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_sub_choices(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'sub_choices'):
|
||||
raw = data.sub_choices
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = []
|
||||
else:
|
||||
parsed = []
|
||||
return {
|
||||
'id': data.id,
|
||||
'set_id': data.set_id,
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'sub_choices': parsed,
|
||||
'disables_subset': data.disables_subset or False,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class PreferenceSetCreate(BaseModel):
|
||||
name: str
|
||||
choices: List[PreferenceChoiceCreate] = []
|
||||
default_choice_index: Optional[int] = None # index into choices (0-based)
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
|
||||
class PreferenceSetOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
name: str
|
||||
choices: List[PreferenceChoiceOut] = []
|
||||
default_choice_id: Optional[int] = None
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_shared_subset(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'shared_subset'):
|
||||
raw = data.shared_subset
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
else:
|
||||
parsed = None
|
||||
return {
|
||||
'id': data.id,
|
||||
'product_id': data.product_id,
|
||||
'name': data.name,
|
||||
'choices': list(data.choices),
|
||||
'default_choice_id': data.default_choice_id,
|
||||
'shared_subset': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
category_id: Optional[int] = None
|
||||
base_price: float
|
||||
is_available: bool = True
|
||||
lifecycle_status: str = "active"
|
||||
printer_zone_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
quick_options: List[ProductQuickOptionCreate] = []
|
||||
options: List[ProductOptionCreate] = []
|
||||
ingredients: List[ProductIngredientCreate] = []
|
||||
preference_sets: List[PreferenceSetCreate] = []
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: Optional[float] = None
|
||||
is_available: Optional[bool] = None
|
||||
lifecycle_status: Optional[str] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
quick_options: Optional[List[ProductQuickOptionCreate]] = None
|
||||
options: Optional[List[ProductOptionCreate]] = None
|
||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||
|
||||
|
||||
class ProductReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class ProductOut(ProductBase):
|
||||
id: int
|
||||
quick_options: List[ProductQuickOptionOut] = []
|
||||
options: List[ProductOptionOut] = []
|
||||
ingredients: List[ProductIngredientOut] = []
|
||||
preference_sets: List[PreferenceSetOut] = []
|
||||
image_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
16
local_backend/schemas/settings.py
Normal file
16
local_backend/schemas/settings.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class PosSettingOut(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
updated_at: Optional[UTCDatetime] = None
|
||||
updated_by_id: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdateSettingRequest(BaseModel):
|
||||
value: str
|
||||
38
local_backend/schemas/shift.py
Normal file
38
local_backend/schemas/shift.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class ShiftBreakOut(BaseModel):
|
||||
id: int
|
||||
shift_id: int
|
||||
started_at: UTCDatetime
|
||||
ended_at: Optional[UTCDatetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WaiterShiftOut(BaseModel):
|
||||
id: int
|
||||
waiter_id: int
|
||||
waiter_name: Optional[str] = None
|
||||
business_day_id: int
|
||||
started_at: UTCDatetime
|
||||
ended_at: Optional[UTCDatetime] = None
|
||||
starting_cash: Optional[float] = None
|
||||
total_collected: Optional[float] = None
|
||||
net_to_deliver: Optional[float] = None
|
||||
is_active: bool = True
|
||||
notes: Optional[str] = None
|
||||
breaks: List[ShiftBreakOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class StartShiftRequest(BaseModel):
|
||||
starting_cash: Optional[float] = None
|
||||
waiter_id: Optional[int] = None # manager use: start shift for a specific waiter
|
||||
|
||||
|
||||
class EndShiftRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
87
local_backend/schemas/table.py
Normal file
87
local_backend/schemas/table.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List
|
||||
|
||||
MAX_TABLE_NAME_LENGTH = 6
|
||||
|
||||
|
||||
class TableGroupCreate(BaseModel):
|
||||
name: str
|
||||
prefix: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
prefix: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
prefix: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TableBase(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TableCreate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def label_max_length(cls, v):
|
||||
if v is not None:
|
||||
v = v.strip()
|
||||
if len(v) > MAX_TABLE_NAME_LENGTH:
|
||||
raise ValueError(f"Table name cannot exceed {MAX_TABLE_NAME_LENGTH} characters")
|
||||
return v
|
||||
|
||||
|
||||
class TableBatchCreate(BaseModel):
|
||||
group_id: Optional[int] = None
|
||||
count: int
|
||||
name_prefix: str # e.g. "TBL-" → TBL-1, TBL-2 ...
|
||||
# start_number is computed on the backend from existing tables in the group
|
||||
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def label_max_length(cls, v):
|
||||
if v is not None:
|
||||
v = v.strip()
|
||||
if len(v) > MAX_TABLE_NAME_LENGTH:
|
||||
raise ValueError(f"Table name cannot exceed {MAX_TABLE_NAME_LENGTH} characters")
|
||||
return v
|
||||
|
||||
|
||||
class TableFloorplanUpdate(BaseModel):
|
||||
floor_x: float
|
||||
floor_y: float
|
||||
|
||||
|
||||
class TableOut(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
group: Optional[TableGroupOut] = None
|
||||
has_active_order: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
61
local_backend/schemas/user.py
Normal file
61
local_backend/schemas/user.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
role: str
|
||||
is_active: bool = True
|
||||
full_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pin: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
full_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
id: int
|
||||
waiter_id: int
|
||||
group_id: Optional[int] = None # None = all zones
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
created_at: UTCDatetime
|
||||
zone_assignments: List[WaiterZoneOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SetZonesRequest(BaseModel):
|
||||
"""Replace all zone assignments for a waiter in one call.
|
||||
group_ids=[] means remove all (sees nothing).
|
||||
group_ids=[null] or all_zones=True means the wildcard 'all zones' sentinel."""
|
||||
group_ids: Optional[List[Optional[int]]] = None # list of group ids; None in list = all-zones sentinel
|
||||
all_zones: bool = False # convenience flag: if True, set a single NULL-group_id row
|
||||
|
||||
|
||||
class AssistantAssignmentOut(BaseModel):
|
||||
id: int
|
||||
primary_waiter_id: int
|
||||
assistant_waiter_id: int
|
||||
assigned_at: UTCDatetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user