diff --git a/local_backend/main.py b/local_backend/main.py
index db20c2e..a7ca7fd 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -22,6 +22,7 @@ import models.message # noqa: F401 — registers StaffMessage, StaffMessag
import models.reservation # noqa: F401
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
+import models.customers # noqa: F401 — registers Customer
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -35,6 +36,7 @@ from routers import connect_orders as connect_orders_router
from routers import reservations as reservations_router
from routers import notes as notes_router
from routers.expenses import contacts_router, expenses_router
+from routers import customers as customers_router
def _run_migrations():
@@ -273,6 +275,19 @@ def _run_migrations():
# Phase 2B — staff payroll
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
+ # Phase 2F — customer CRM
+ """CREATE TABLE IF NOT EXISTS customers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name VARCHAR NOT NULL,
+ nickname VARCHAR,
+ phone VARCHAR,
+ email VARCHAR,
+ notes TEXT,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ created_by_id INTEGER NOT NULL REFERENCES users(id)
+ )""",
+ "ALTER TABLE orders ADD COLUMN customer_id INTEGER REFERENCES customers(id)",
# Phase 2E — cash drawer reconciliation
"ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL",
"ALTER TABLE waiter_shifts ADD COLUMN cash_discrepancy REAL",
@@ -396,3 +411,4 @@ app.include_router(reservations_router.router, prefix="/api/reservations", tag
app.include_router(notes_router.router, prefix="/api/notes", tags=["notes"])
app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
+app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
diff --git a/local_backend/models/customers.py b/local_backend/models/customers.py
new file mode 100644
index 0000000..f9d7e34
--- /dev/null
+++ b/local_backend/models/customers.py
@@ -0,0 +1,25 @@
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
+from sqlalchemy.orm import relationship
+from datetime import datetime, timezone
+from database import Base
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+class Customer(Base):
+ __tablename__ = "customers"
+
+ id = Column(Integer, primary_key=True, index=True)
+ name = Column(String, nullable=False)
+ nickname = Column(String, nullable=True)
+ phone = Column(String, nullable=True)
+ email = Column(String, nullable=True)
+ notes = Column(Text, nullable=True)
+ is_active = Column(Boolean, default=True, nullable=False)
+ created_at = Column(DateTime(timezone=True), default=_utcnow)
+ created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+
+ created_by = relationship("User", foreign_keys=[created_by_id])
+ orders = relationship("Order", back_populates="customer")
diff --git a/local_backend/models/order.py b/local_backend/models/order.py
index 69c8074..30436be 100644
--- a/local_backend/models/order.py
+++ b/local_backend/models/order.py
@@ -30,10 +30,13 @@ class Order(Base):
online_customer_address = Column(Text, nullable=True)
online_customer_notes = Column(Text, nullable=True)
online_order_type = Column(String, nullable=True) # "delivery" | "dine_in"
+ # Phase 2F — customer CRM
+ customer_id = Column(Integer, ForeignKey("customers.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")
+ customer = relationship("Customer", back_populates="orders", foreign_keys=[customer_id])
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")
diff --git a/local_backend/routers/customers.py b/local_backend/routers/customers.py
new file mode 100644
index 0000000..ab7a900
--- /dev/null
+++ b/local_backend/routers/customers.py
@@ -0,0 +1,117 @@
+from fastapi import APIRouter, Depends, HTTPException, status, Query
+from sqlalchemy.orm import Session
+from typing import List, Optional
+
+from database import get_db
+from models.customers import Customer
+from models.order import Order, OrderItem
+from models.user import User
+from schemas.customers import CustomerCreate, CustomerUpdate, CustomerOut, AssignCustomerRequest
+from routers.deps import require_manager, get_current_user
+
+router = APIRouter()
+
+
+def _customer_out(c: Customer, db: Session) -> dict:
+ orders = db.query(Order).filter(
+ Order.customer_id == c.id,
+ Order.status.in_(["closed", "paid"]),
+ ).all()
+ total_spent = sum(
+ sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
+ for o in orders
+ )
+ return {
+ "id": c.id,
+ "name": c.name,
+ "nickname": c.nickname,
+ "phone": c.phone,
+ "email": c.email,
+ "notes": c.notes,
+ "is_active": c.is_active,
+ "created_at": c.created_at,
+ "created_by_id": c.created_by_id,
+ "visit_count": len(orders),
+ "total_spent": round(total_spent, 2),
+ }
+
+
+@router.get("/", response_model=List[CustomerOut])
+def list_customers(
+ search: Optional[str] = None,
+ include_inactive: bool = False,
+ db: Session = Depends(get_db),
+ user: User = Depends(require_manager),
+):
+ q = db.query(Customer)
+ if not include_inactive:
+ q = q.filter(Customer.is_active == True)
+ if search:
+ term = f"%{search}%"
+ q = q.filter(
+ Customer.name.ilike(term) |
+ Customer.nickname.ilike(term) |
+ Customer.phone.ilike(term)
+ )
+ customers = q.order_by(Customer.name).all()
+ return [_customer_out(c, db) for c in customers]
+
+
+@router.post("/", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
+def create_customer(body: CustomerCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ customer = Customer(**body.model_dump(), created_by_id=user.id)
+ db.add(customer)
+ db.commit()
+ db.refresh(customer)
+ return _customer_out(customer, db)
+
+
+@router.get("/{customer_id}", response_model=CustomerOut)
+def get_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ c = db.query(Customer).filter(Customer.id == customer_id).first()
+ if not c:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ return _customer_out(c, db)
+
+
+@router.put("/{customer_id}", response_model=CustomerOut)
+def update_customer(customer_id: int, body: CustomerUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ c = db.query(Customer).filter(Customer.id == customer_id).first()
+ if not c:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ for field, value in body.model_dump(exclude_none=True).items():
+ setattr(c, field, value)
+ db.commit()
+ db.refresh(c)
+ return _customer_out(c, db)
+
+
+@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ c = db.query(Customer).filter(Customer.id == customer_id).first()
+ if not c:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ c.is_active = False
+ db.commit()
+
+
+@router.get("/{customer_id}/orders")
+def customer_orders(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ c = db.query(Customer).filter(Customer.id == customer_id).first()
+ if not c:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ from models.table import Table
+ orders = db.query(Order).filter(Order.customer_id == customer_id).order_by(Order.opened_at.desc()).all()
+ tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
+ result = []
+ for o in orders:
+ total = sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
+ result.append({
+ "id": o.id,
+ "table_name": tables_map.get(o.table_id) if o.table_id else None,
+ "opened_at": o.opened_at,
+ "status": o.status,
+ "total": round(total, 2),
+ "item_count": sum(i.quantity for i in o.items if i.status in ("active", "paid")),
+ })
+ return {"orders": result}
diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py
index a5be867..b2f3376 100644
--- a/local_backend/routers/orders.py
+++ b/local_backend/routers/orders.py
@@ -191,6 +191,15 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
table_name = (table.label or f"T{table.number}") if table else None
+ # Phase 2F: resolve customer name for display
+ customer_name = None
+ customer_phone = None
+ if order.customer_id and order.customer:
+ customer_name = order.customer.name
+ if order.customer.nickname:
+ customer_name = f"{order.customer.name} «{order.customer.nickname}»"
+ customer_phone = order.customer.phone
+
return {
"id": order.id,
"table_id": order.table_id,
@@ -202,6 +211,9 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"closed_by": order.closed_by,
"notes": order.notes,
"business_day_id": order.business_day_id,
+ "customer_id": order.customer_id,
+ "customer_name": customer_name,
+ "customer_phone": customer_phone,
"items": [fmt_item(i) for i in order.items],
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
"audit_logs": [fmt_log(l) for l in order.audit_logs],
@@ -567,6 +579,26 @@ def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depend
return {"status": "assigned"}
+class AssignCustomerBody(BaseModel):
+ customer_id: Optional[int] = None # null = unassign
+
+
+@router.put("/{order_id}/customer")
+def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ from models.customers import Customer
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ if body.customer_id is not None:
+ customer = db.query(Customer).filter(Customer.id == body.customer_id, Customer.is_active == True).first()
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ order.customer_id = body.customer_id
+ db.commit()
+ broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "customer_assigned"})
+ return {"status": "ok", "customer_id": order.customer_id}
+
+
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
assignment = db.query(OrderWaiter).filter(
diff --git a/local_backend/schemas/customers.py b/local_backend/schemas/customers.py
new file mode 100644
index 0000000..2ec6bb3
--- /dev/null
+++ b/local_backend/schemas/customers.py
@@ -0,0 +1,41 @@
+from pydantic import BaseModel
+from typing import Optional, List
+from datetime import datetime
+
+
+class CustomerCreate(BaseModel):
+ name: str
+ nickname: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ notes: Optional[str] = None
+
+
+class CustomerUpdate(BaseModel):
+ name: Optional[str] = None
+ nickname: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ notes: Optional[str] = None
+ is_active: Optional[bool] = None
+
+
+class CustomerOut(BaseModel):
+ id: int
+ name: str
+ nickname: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ notes: Optional[str] = None
+ is_active: bool
+ created_at: datetime
+ created_by_id: int
+ # computed stats (set by router)
+ visit_count: int = 0
+ total_spent: float = 0.0
+
+ model_config = {"from_attributes": True}
+
+
+class AssignCustomerRequest(BaseModel):
+ customer_id: Optional[int] = None # null = unassign
diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx
index e599ce5..bd4046b 100644
--- a/manager_dashboard/src/App.jsx
+++ b/manager_dashboard/src/App.jsx
@@ -14,6 +14,7 @@ import OnlineOrdersPage from './pages/OnlineOrdersPage'
import NotesPage from './pages/NotesPage'
import ContactsPage from './pages/ContactsPage'
import ExpensesPage from './pages/ExpensesPage'
+import CustomersPage from './pages/CustomersPage'
import client from './api/client'
function Spinner() {
@@ -87,6 +88,7 @@ export default function App() {
{activeCount} ενεργοί πελάτες
+