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:
2026-06-08 02:59:53 +03:00
parent d39382e8dd
commit e9bdf61a4d
10 changed files with 648 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ import models.message # noqa: F401 — registers StaffMessage, StaffMessag
import models.reservation # noqa: F401 import models.reservation # noqa: F401
import models.notes # noqa: F401 — registers SiteNote, SiteTodo import models.notes # noqa: F401 — registers SiteNote, SiteTodo
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment 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 auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_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 reservations as reservations_router
from routers import notes as notes_router from routers import notes as notes_router
from routers.expenses import contacts_router, expenses_router from routers.expenses import contacts_router, expenses_router
from routers import customers as customers_router
def _run_migrations(): def _run_migrations():
@@ -273,6 +275,19 @@ def _run_migrations():
# Phase 2B — staff payroll # Phase 2B — staff payroll
"ALTER TABLE users ADD COLUMN hourly_rate REAL", "ALTER TABLE users ADD COLUMN hourly_rate REAL",
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot 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 # Phase 2E — cash drawer reconciliation
"ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL", "ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL",
"ALTER TABLE waiter_shifts ADD COLUMN cash_discrepancy 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(notes_router.router, prefix="/api/notes", tags=["notes"])
app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"]) app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"]) app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])

View File

@@ -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")

View File

@@ -30,10 +30,13 @@ class Order(Base):
online_customer_address = Column(Text, nullable=True) online_customer_address = Column(Text, nullable=True)
online_customer_notes = Column(Text, nullable=True) online_customer_notes = Column(Text, nullable=True)
online_order_type = Column(String, nullable=True) # "delivery" | "dine_in" 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") table = relationship("Table", back_populates="orders")
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened") opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed") 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") items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
waiters = relationship("OrderWaiter", 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") print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")

View File

@@ -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}

View File

@@ -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 = 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 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 { return {
"id": order.id, "id": order.id,
"table_id": order.table_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, "closed_by": order.closed_by,
"notes": order.notes, "notes": order.notes,
"business_day_id": order.business_day_id, "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], "items": [fmt_item(i) for i in order.items],
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters], "waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
"audit_logs": [fmt_log(l) for l in order.audit_logs], "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"} 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) @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)): def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
assignment = db.query(OrderWaiter).filter( assignment = db.query(OrderWaiter).filter(

View File

@@ -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

View File

@@ -14,6 +14,7 @@ import OnlineOrdersPage from './pages/OnlineOrdersPage'
import NotesPage from './pages/NotesPage' import NotesPage from './pages/NotesPage'
import ContactsPage from './pages/ContactsPage' import ContactsPage from './pages/ContactsPage'
import ExpensesPage from './pages/ExpensesPage' import ExpensesPage from './pages/ExpensesPage'
import CustomersPage from './pages/CustomersPage'
import client from './api/client' import client from './api/client'
function Spinner() { function Spinner() {
@@ -87,6 +88,7 @@ export default function App() {
<Route path="notes" element={<NotesPage />} /> <Route path="notes" element={<NotesPage />} />
<Route path="contacts" element={<ContactsPage />} /> <Route path="contacts" element={<ContactsPage />} />
<Route path="expenses" element={<ExpensesPage />} /> <Route path="expenses" element={<ExpensesPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="online-orders" element={<OnlineOrdersPage />} /> <Route path="online-orders" element={<OnlineOrdersPage />} />
<Route path="reports" element={<ReportsPage />} /> <Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />

View File

@@ -1,6 +1,6 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react' 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' import { getIncomingOrders } from '../api/client'
export default function Sidebar() { export default function Sidebar() {
@@ -29,6 +29,7 @@ export default function Sidebar() {
{ to: '/notes', icon: NotebookPen, label: 'Σημειώσεις' }, { to: '/notes', icon: NotebookPen, label: 'Σημειώσεις' },
{ to: '/expenses', icon: Receipt, label: 'Έξοδα' }, { to: '/expenses', icon: Receipt, label: 'Έξοδα' },
{ to: '/contacts', icon: BookUser, label: 'Επαφές' }, { to: '/contacts', icon: BookUser, label: 'Επαφές' },
{ to: '/customers', icon: Users, label: 'Πελάτες' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
] ]

View File

@@ -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 (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 480, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέος Πελάτης' : 'Επεξεργασία Πελάτη'}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}></button>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Γιώργος Παπαδόπουλος" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Παρατσούκλι</label>
<input style={inputStyle} value={form.nickname} onChange={e => f('nickname', e.target.value)} placeholder="π.χ. Μεγάλος Γιάννης" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="email@example.com" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(αλλεργίες, προτιμήσεις)</span></label>
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="π.χ. Αλλεργία στους ξηρούς καρπούς, VIP πελάτης…" />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button
onClick={() => onSave(form)}
disabled={!form.name.trim() || isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: form.name.trim() ? 'pointer' : 'default', background: form.name.trim() ? '#111827' : '#e5e7eb', color: form.name.trim() ? 'white' : '#9ca3af' }}
>
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)
}
// ── Customer detail panel ─────────────────────────────────────────────────────
function CustomerDetail({ customer, onEdit, onClose }) {
const { data, isLoading } = useQuery({
queryKey: ['customer-orders', customer.id],
queryFn: () => client.get(`/api/customers/${customer.id}/orders`).then(r => r.data),
staleTime: 30_000,
})
const orders = data?.orders || []
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'white', borderLeft: '1px solid #f0f0ef' }}>
{/* Header */}
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 800, color: '#111315' }}>{customer.name}</div>
{customer.nickname && <div style={{ fontSize: 13, color: '#9ca3af', marginTop: 2 }}>«{customer.nickname}»</div>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={onEdit} style={{ padding: '5px 12px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 12, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>Επεξεργασία</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af', padding: '0 4px' }}></button>
</div>
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 8, padding: '14px 22px', flexShrink: 0, borderBottom: '1px solid #f0f0ef' }}>
{[
{ label: 'Επισκέψεις', value: customer.visit_count },
{ label: 'Σύνολο εξόδων', value: fmt(customer.total_spent) },
{ label: 'Μέσος λογ.', value: customer.visit_count > 0 ? fmt(customer.total_spent / customer.visit_count) : '—' },
].map(s => (
<div key={s.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
<div style={{ fontSize: 16, fontWeight: 800, color: '#111315', marginTop: 3 }}>{s.value}</div>
</div>
))}
</div>
{/* Contact info */}
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
{(customer.phone || customer.email || customer.notes) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
{customer.phone && <div><span style={{ color: '#9ca3af', marginRight: 6 }}>📞</span>{customer.phone}</div>}
{customer.email && <div><span style={{ color: '#9ca3af', marginRight: 6 }}></span>{customer.email}</div>}
{customer.notes && (
<div style={{ marginTop: 4, padding: '8px 12px', background: '#fffbeb', borderRadius: 8, border: '1px solid #fef3c7', fontSize: 12.5, color: '#92400e', lineHeight: 1.5 }}>
📝 {customer.notes}
</div>
)}
</div>
) : (
<div style={{ fontSize: 12.5, color: '#d1d5db' }}>Δεν υπάρχουν στοιχεία επικοινωνίας.</div>
)}
</div>
{/* Visit history */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>Ιστορικό Επισκέψεων</div>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13 }}>Φόρτωση</div>}
{!isLoading && orders.length === 0 && (
<div style={{ color: '#d1d5db', fontSize: 13 }}>Δεν υπάρχουν επισκέψεις ακόμα.</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{orders.map(o => (
<div key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid #f0f0ef', borderRadius: 10, background: 'white' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
{o.table_name ? `Τραπέζι ${o.table_name}` : 'Χωρίς τραπέζι'}
{' '}
<span style={{ fontSize: 11, fontWeight: 700, padding: '1px 6px', borderRadius: 99, background: '#f3f4f6', color: ORDER_STATUS_COLORS[o.status] || '#6b7280' }}>
{ORDER_STATUS_LABELS[o.status] || o.status}
</span>
</div>
<div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>
{fmtDateTime(o.opened_at)} · {o.item_count} αντικ.
</div>
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', whiteSpace: 'nowrap' }}>{fmt(o.total)}</div>
</div>
))}
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function CustomersPage() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState(null) // customer being viewed
const [modal, setModal] = useState(null) // null | { customer } | { customer: null }
const { data: customers = [], isLoading } = useQuery({
queryKey: ['customers', search],
queryFn: () => client.get('/api/customers/', { params: search ? { search } : {} }).then(r => r.data),
staleTime: 30_000,
})
const save = useMutation({
mutationFn: ({ form, id }) => id
? client.put(`/api/customers/${id}`, form)
: client.post('/api/customers/', form),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['customers'] })
if (selected && res.data?.id === selected.id) {
setSelected(res.data)
}
setModal(null)
toast.success('Αποθηκεύτηκε')
},
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deactivate = useMutation({
mutationFn: (id) => client.delete(`/api/customers/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['customers'] })
if (selected?.id) setSelected(null)
toast.success('Αποενεργοποιήθηκε')
},
onError: () => toast.error('Σφάλμα'),
})
const activeCount = customers.length
return (
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* Left: list */}
<div style={{ display: 'flex', flexDirection: 'column', width: selected ? 360 : '100%', flexShrink: 0, borderRight: selected ? '1px solid #f0f0ef' : 'none', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 24px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πελάτες</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
</div>
<button
onClick={() => setModal({ customer: null })}
style={{ padding: '8px 16px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
+ Νέος
</button>
</div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Αναζήτηση με όνομα, παρατσούκλι ή τηλέφωνο…"
style={{ ...inputStyle, fontSize: 13 }}
/>
</div>
{/* List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>Φόρτωση</div>}
{!isLoading && customers.length === 0 && (
<div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '32px 0' }}>
{search ? 'Δεν βρέθηκαν αποτελέσματα.' : 'Δεν υπάρχουν πελάτες ακόμα.'}
</div>
)}
{customers.map(c => (
<button
key={c.id}
onClick={() => setSelected(selected?.id === c.id ? null : c)}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px',
border: `1px solid ${selected?.id === c.id ? '#3b82f6' : '#f0f0ef'}`,
borderRadius: 10, background: selected?.id === c.id ? '#eff6ff' : 'white',
cursor: 'pointer', textAlign: 'left', width: '100%',
}}
>
{/* Avatar */}
<div style={{
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
background: `hsl(${[...c.name].reduce((a, ch) => a + ch.charCodeAt(0), 0) % 360},55%,62%)`,
color: 'white', fontSize: 15, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{c.name.trim()[0].toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 6 }}>
{c.name}
{c.nickname && <span style={{ fontSize: 11, fontWeight: 500, color: '#9ca3af' }}>«{c.nickname}»</span>}
</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
{c.phone || c.email || 'Χωρίς στοιχεία'}
{c.visit_count > 0 && <span style={{ marginLeft: 8, color: '#6b7280' }}>· {c.visit_count} επισκέψεις</span>}
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', whiteSpace: 'nowrap', flexShrink: 0 }}>
{c.total_spent > 0 ? fmt(c.total_spent) : ''}
</div>
</button>
))}
</div>
</div>
{/* Right: detail panel */}
{selected && (
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<CustomerDetail
customer={selected}
onEdit={() => setModal({ customer: selected })}
onClose={() => setSelected(null)}
/>
</div>
)}
{modal && (
<CustomerModal
initial={modal.customer ? {
...modal.customer,
nickname: modal.customer.nickname || '',
phone: modal.customer.phone || '',
email: modal.customer.email || '',
notes: modal.customer.notes || '',
} : null}
onClose={() => setModal(null)}
isPending={save.isPending}
onSave={(form) => save.mutate({
form: {
name: form.name,
nickname: form.nickname || null,
phone: form.phone || null,
email: form.email || null,
notes: form.notes || null,
},
id: modal.customer?.id,
})}
/>
)}
</div>
)
}

View File

@@ -157,6 +157,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const [confirmAction, setConfirmAction] = useState(null) // { type, payload } const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
const [showPrintModal, setShowPrintModal] = useState(false) const [showPrintModal, setShowPrintModal] = useState(false)
const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
const { data: order, isLoading } = useQuery({ const { data: order, isLoading } = useQuery({
queryKey: ['order', orderId], queryKey: ['order', orderId],
@@ -220,6 +222,19 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
const { data: customersData = [] } = useQuery({
queryKey: ['customers', customerSearch],
queryFn: () => client.get('/api/customers/', { params: customerSearch ? { search: customerSearch } : {} }).then(r => r.data),
staleTime: 30_000,
enabled: showCustomerDropdown,
})
const assignCustomer = useMutation({
mutationFn: (customer_id) => client.put(`/api/orders/${orderId}/customer`, { customer_id }),
onSuccess: (_, customer_id) => { toast.success(customer_id ? 'Πελάτης ανατέθηκε' : 'Πελάτης αφαιρέθηκε'); invalidate(); setShowCustomerDropdown(false); setCustomerSearch('') },
onError: () => toast.error('Σφάλμα'),
})
const payItems = useMutation({ const payItems = useMutation({
mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }), mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }),
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() }, onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
@@ -277,6 +292,73 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div> </div>
</div> </div>
{/* Customer assignment */}
<div className="card p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-gray-700">Πελάτης</h2>
{order.customer_id && isOpen && !readOnly && (
<button
onClick={() => assignCustomer.mutate(null)}
className="text-xs text-gray-400 hover:text-red-500"
>
Αφαίρεση
</button>
)}
</div>
{order.customer_id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', background: '#eff6ff', borderRadius: 8, border: '1px solid #bfdbfe' }}>
<span style={{ fontSize: 16 }}>👤</span>
<div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: '#1d4ed8' }}>{order.customer_name || `Πελάτης #${order.customer_id}`}</div>
{order.customer_phone && <div style={{ fontSize: 11.5, color: '#3b82f6' }}>{order.customer_phone}</div>}
</div>
</div>
) : isOpen && !readOnly ? (
<div style={{ position: 'relative' }}>
{!showCustomerDropdown ? (
<button
onClick={() => setShowCustomerDropdown(true)}
style={{ fontSize: 13, color: '#6b7280', border: '1px dashed #d1d5db', borderRadius: 8, padding: '7px 14px', cursor: 'pointer', background: 'white', width: '100%', textAlign: 'left' }}
>
+ Ανάθεση πελάτη
</button>
) : (
<div>
<input
autoFocus
value={customerSearch}
onChange={e => setCustomerSearch(e.target.value)}
placeholder="Αναζήτηση πελάτη…"
style={{ width: '100%', padding: '7px 11px', border: '1.5px solid #3b82f6', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.10)', zIndex: 20, maxHeight: 220, overflowY: 'auto', marginTop: 4 }}>
{customersData.length === 0 && (
<div style={{ padding: '10px 14px', fontSize: 13, color: '#9ca3af' }}>Δεν βρέθηκαν πελάτες</div>
)}
{customersData.map(c => (
<button key={c.id} onClick={() => assignCustomer.mutate(c.id)}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '9px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, borderBottom: '1px solid #f3f4f6' }}
className="hover:bg-blue-50"
>
<span style={{ fontWeight: 600, color: '#111315' }}>{c.name}</span>
{c.nickname && <span style={{ color: '#9ca3af', marginLeft: 5 }}>«{c.nickname}»</span>}
{c.phone && <span style={{ color: '#6b7280', marginLeft: 8, fontSize: 12 }}>{c.phone}</span>}
</button>
))}
<button onClick={() => { setShowCustomerDropdown(false); setCustomerSearch('') }}
style={{ display: 'block', width: '100%', padding: '8px 14px', fontSize: 12, color: '#9ca3af', border: 'none', background: 'none', cursor: 'pointer', borderTop: '1px solid #f0f0ef' }}>
Ακύρωση
</button>
</div>
</div>
)}
</div>
) : (
<div style={{ fontSize: 13, color: '#d1d5db' }}>Χωρίς πελάτη</div>
)}
</div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 border-b border-gray-200"> <div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => ( {[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (