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

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