feat: Phase 2F — customer CRM
Backend:
- New model: Customer (customers table) — name, nickname, phone, email, notes, is_active
- Order model: add customer_id nullable FK → customers
- New router: /api/customers/ — list (with ?search=), create, get, update, soft-delete,
GET /{id}/orders (visit history with totals)
- Each CustomerOut includes visit_count + total_spent (computed from closed/paid orders)
- PUT /api/orders/{id}/customer — assign or unassign a customer; manager-only;
validates customer is active; broadcasts order_updated SSE event
- GET /api/orders/{id} now returns customer_id, customer_name (with nickname), customer_phone
- Migration: CREATE TABLE customers + ALTER TABLE orders ADD COLUMN customer_id
Frontend:
- CustomersPage (/customers): searchable list (name/nickname/phone), avatar initials,
visit count + total_spent on each row; click → detail panel slides in showing stats
(visits, total spent, avg ticket), contact info, notes, full visit history
- OrderDetailPage: new Πελάτης card — shows assigned customer in blue if set;
for open orders shows "+ Ανάθεση πελάτη" button → inline search dropdown → assign;
Αφαίρεση button to unassign
- Sidebar: Users icon for Πελάτες (between Επαφές and Ρυθμίσεις)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user