Backend:
- New models: Tab, TabEntry, TabPayment (3 new tables)
- Tab: open|closed|forgiven, one open tab per customer enforced
- TabEntry: what went ON the tab (per-item snapshot with description)
- TabPayment: what came OFF the tab; balance = sum(entries) - sum(payments), never stored
- New router /api/tabs/:
- GET / — list open tabs (sorted by balance desc); ?tab_status= override
- GET /{id} — full tab detail with entries + payments
- POST / — open new tab for a customer (400 if one already exists)
- POST /{id}/entries — add entry directly (amount + description)
- POST /{id}/pay — record payment; validates amount <= balance
- POST /{id}/close — close tab (requires balance = 0)
- POST /{id}/forgive — write off remaining balance
- GET /customer/{customer_id} — all tabs for a customer (open first)
- POST /api/orders/{id}/items/{item_id}/tab:
- Requires order has a customer assigned
- Finds or auto-creates open tab for that customer
- Sets order_item.status = "tabbed" (new valid status value)
- Creates TabEntry with auto-generated description
- Broadcasts order_updated SSE event
- Migrations: CREATE TABLE IF NOT EXISTS for tabs, tab_entries, tab_payments
Frontend:
- TabsPage (/tabs): open tabs list sorted by balance; each card shows charges/payments
history, live balance, Πληρωμή modal (defaults to full balance), Κλείσιμο button
(only shown when balance=0), Χάρισμα Υπολοίπου with confirm modal + reason
- CustomersPage CustomerDetail: open tab banner showing balance + entry count
(appears between stats and contact info when customer has an open tab)
- OrderDetailPage: 📋 Καρτέλα button appears on active items when order has a customer;
tabbed items show a 📋 Καρτέλα badge; tabItem mutation calls /items/{id}/tab
- Sidebar: CreditCard icon for Καρτέλες (after Πελάτες)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
351 lines
18 KiB
JavaScript
351 lines
18 KiB
JavaScript
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 { data: tabsData = [] } = useQuery({
|
|
queryKey: ['customer-tabs', customer.id],
|
|
queryFn: () => client.get(`/api/tabs/customer/${customer.id}`).then(r => r.data),
|
|
staleTime: 30_000,
|
|
})
|
|
|
|
const openTab = tabsData.find(t => t.status === 'open')
|
|
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>
|
|
|
|
{/* Open tab banner */}
|
|
{openTab && (
|
|
<div style={{ padding: '10px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10 }}>
|
|
<div>
|
|
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#dc2626' }}>Ανοιχτή Καρτέλα</div>
|
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
|
{openTab.entries.length} χρεώσεις · πληρωμένο {fmt(openTab.total_paid)}
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 18, fontWeight: 800, color: '#dc2626' }}>{fmt(openTab.balance)}</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>
|
|
)
|
|
}
|