Files
xenia-pos-local/manager_dashboard/src/pages/CustomersPage.jsx
bonamin 2ff48730f7 feat: Phase 2G — pay-later tab system
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>
2026-06-08 03:07:22 +03:00

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