From e9bdf61a4d9de16f30b1713455bb5a99060fda86 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 8 Jun 2026 02:59:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202F=20=E2=80=94=20customer=20CRM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- local_backend/main.py | 16 + local_backend/models/customers.py | 25 ++ local_backend/models/order.py | 3 + local_backend/routers/customers.py | 117 +++++++ local_backend/routers/orders.py | 32 ++ local_backend/schemas/customers.py | 41 +++ manager_dashboard/src/App.jsx | 2 + manager_dashboard/src/components/Sidebar.jsx | 3 +- manager_dashboard/src/pages/CustomersPage.jsx | 328 ++++++++++++++++++ .../src/pages/OrderDetailPage.jsx | 82 +++++ 10 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 local_backend/models/customers.py create mode 100644 local_backend/routers/customers.py create mode 100644 local_backend/schemas/customers.py create mode 100644 manager_dashboard/src/pages/CustomersPage.jsx 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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index 4f9e9d9..adff9ba 100644 --- a/manager_dashboard/src/components/Sidebar.jsx +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -1,6 +1,6 @@ import { NavLink } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' -import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser } from 'lucide-react' +import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users } from 'lucide-react' import { getIncomingOrders } from '../api/client' export default function Sidebar() { @@ -29,6 +29,7 @@ export default function Sidebar() { { to: '/notes', icon: NotebookPen, label: 'Σημειώσεις' }, { to: '/expenses', icon: Receipt, label: 'Έξοδα' }, { to: '/contacts', icon: BookUser, label: 'Επαφές' }, + { to: '/customers', icon: Users, label: 'Πελάτες' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, ] diff --git a/manager_dashboard/src/pages/CustomersPage.jsx b/manager_dashboard/src/pages/CustomersPage.jsx new file mode 100644 index 0000000..9b56ef0 --- /dev/null +++ b/manager_dashboard/src/pages/CustomersPage.jsx @@ -0,0 +1,328 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../api/client' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fmt(n) { + if (n == null) return '—' + return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' +} + +function fmtDate(iso) { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +function fmtDateTime(iso) { + if (!iso) return '—' + return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + +const ORDER_STATUS_LABELS = { + closed: 'Κλειστή', paid: 'Πληρωμένη', open: 'Ανοιχτή', + partially_paid: 'Μερική πλ.', cancelled: 'Ακυρωμένη', +} +const ORDER_STATUS_COLORS = { + closed: '#6b7280', paid: '#16a34a', open: '#3b82f6', + partially_paid: '#d97706', cancelled: '#ef4444', +} + +const inputStyle = { + width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', + borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white', +} + +// ── Customer modal (create / edit) ─────────────────────────────────────────── + +const EMPTY_FORM = { name: '', nickname: '', phone: '', email: '', notes: '' } + +function CustomerModal({ initial, onClose, onSave, isPending }) { + const [form, setForm] = useState(initial ?? EMPTY_FORM) + const f = (k, v) => setForm(p => ({ ...p, [k]: v })) + const isNew = !initial?.id + + return ( +
+
+
+

{isNew ? 'Νέος Πελάτης' : 'Επεξεργασία Πελάτη'}

+ +
+
+
+ + f('name', e.target.value)} autoFocus placeholder="π.χ. Γιώργος Παπαδόπουλος" /> +
+
+
+ + f('nickname', e.target.value)} placeholder="π.χ. Μεγάλος Γιάννης" /> +
+
+ + f('phone', e.target.value)} placeholder="π.χ. 6912345678" /> +
+
+
+ + f('email', e.target.value)} placeholder="email@example.com" /> +
+
+ +