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

@@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
import bcrypt
from database import get_db
from models.user import User
router = APIRouter()
class SetupStatusResponse(BaseModel):
needs_setup: bool
class SetupInitRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
full_name: Optional[str] = None
venue_type: Optional[str] = None
venue_name: Optional[str] = None
pin: Optional[str] = None
class SetupInitResponse(BaseModel):
ok: bool
class SecurityConfigResponse(BaseModel):
login_method: str
autofill_username: bool
@router.get("/security-config", response_model=SecurityConfigResponse)
def security_config(db: Session = Depends(get_db)):
"""Public endpoint — returns only the security settings needed by the login page."""
from models.settings import PosSettings
rows = {r.key: r.value for r in db.query(PosSettings).filter(
PosSettings.key.in_(["security.login_method", "security.autofill_username"])
).all()}
return SecurityConfigResponse(
login_method=rows.get("security.login_method", "password"),
autofill_username=rows.get("security.autofill_username", "true") == "true",
)
@router.get("/status", response_model=SetupStatusResponse)
def setup_status(db: Session = Depends(get_db)):
has_manager = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.is_active == True,
).first()
return SetupStatusResponse(needs_setup=has_manager is None)
@router.post("/init", response_model=SetupInitResponse)
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
has_manager = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.is_active == True,
).first()
if has_manager:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Setup already completed — a manager account already exists.",
)
if not body.username.strip():
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Username is required.")
if len(body.password) < 6:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Password must be at least 6 characters.")
existing = db.query(User).filter(User.username == body.username.strip()).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
password_hash = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
raw_pin = body.pin if (body.pin and body.pin.isdigit() and 4 <= len(body.pin) <= 6) else "0000"
pin_hash = bcrypt.hashpw(raw_pin.encode(), bcrypt.gensalt()).decode()
user = User(
username=body.username.strip(),
pin_hash=pin_hash,
password_hash=password_hash,
email=body.email,
full_name=body.full_name,
role="manager",
is_active=True,
)
db.add(user)
# Persist venue settings if provided
if body.venue_name or body.venue_type:
from models.settings import PosSettings
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
if body.venue_name:
setting = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
if setting:
setting.value = body.venue_name
setting.updated_at = now
else:
db.add(PosSettings(key="venue.name", value=body.venue_name, updated_at=now))
if body.venue_type:
setting = db.query(PosSettings).filter(PosSettings.key == "venue.type").first()
if setting:
setting.value = body.venue_type
setting.updated_at = now
else:
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
db.commit()
return SetupInitResponse(ok=True)