Files
xenia-pos-local/manager_dashboard/src/pages/TabsPage.jsx
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

412 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ChevronDown, Search } from 'lucide-react'
import client from '../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
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' })
}
function daysSince(iso) {
if (!iso) return null
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
if (days === 0) return 'Σήμερα'
if (days === 1) return '1 μέρα'
return `${days} μέρες`
}
const STATUS_CONFIG = {
open: { label: 'Ανοιχτή', bg: '#fee2e2', color: '#dc2626' },
closed: { label: 'Κλειστή', bg: '#dcfce7', color: '#16a34a' },
forgiven: { label: 'Χαρίστηκε', bg: '#f3f4f6', color: '#6b7280' },
}
// ── Pay modal ─────────────────────────────────────────────────────────────────
function PayModal({ tab, onClose, onPay, isPending }) {
const [amount, setAmount] = useState(String(Math.round(tab.balance * 100) / 100))
const [method, setMethod] = useState('cash')
const [notes, setNotes] = useState('')
const parsed = parseFloat(amount)
const canPay = parsed > 0 && parsed <= tab.balance + 0.005
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>Πληρωμή Καρτέλας</div>
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name}</div>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ background: '#f9fafb', borderRadius: 10, padding: '12px 14px', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>Υπόλοιπο καρτέλας</span>
<span style={{ fontSize: 15, fontWeight: 700, color: '#dc2626' }}>{fmt(tab.balance)}</span>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής ()</label>
<input type="number" step="0.01" min="0.01"
value={amount} onChange={e => setAmount(e.target.value)} autoFocus
style={{ width: '100%', padding: '9px 12px', border: '1.5px solid #e5e7eb', borderRadius: 8, fontSize: 16, fontWeight: 600, outline: 'none', boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τρόπος πληρωμής</label>
<div style={{ display: 'flex', gap: 6 }}>
{[['cash', 'Μετρητά'], ['card', 'Κάρτα'], ['other', 'Άλλο']].map(([v, l]) => (
<button key={v} onClick={() => setMethod(v)}
style={{
flex: 1, padding: '7px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${method === v ? '#111827' : '#e5e7eb'}`,
background: method === v ? '#111827' : 'white',
color: method === v ? 'white' : '#6b7280',
}}>{l}</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="προαιρετικό"
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button onClick={() => onPay(parsed, method, notes || null)} disabled={!canPay || isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}>
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
</button>
</div>
</div>
</div>
)
}
// ── Forgive confirm ───────────────────────────────────────────────────────────
function ForgiveModal({ tab, onClose, onForgive, isPending }) {
const [reason, setReason] = useState('')
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#dc2626' }}>Χάρισμα Καρτέλας</div>
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name} υπόλοιπο {fmt(tab.balance)}</div>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: '12px 14px', background: '#fef2f2', borderRadius: 10, border: '1px solid #fecaca', fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
Το υπόλοιπο των {fmt(tab.balance)} θα διαγραφεί. Η ενέργεια δεν αναιρείται.
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Αιτιολογία</label>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="π.χ. Καλή θέληση"
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button onClick={() => onForgive(reason || null)} disabled={isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer', background: '#dc2626', color: 'white' }}>
{isPending ? 'Αποθήκευση…' : 'Χάρισμα Υπολοίπου'}
</button>
</div>
</div>
</div>
)
}
// ── Tab detail card (collapsible) ─────────────────────────────────────────────
function TabDetail({ tab, onPay, onClose, onForgive, defaultExpanded = false }) {
const [expanded, setExpanded] = useState(defaultExpanded)
const sc = STATUS_CONFIG[tab.status] || STATUS_CONFIG.open
const canClose = tab.status === 'open' && tab.balance <= 0.005 && tab.entries.length > 0
return (
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden', background: 'white' }}>
{/* Clickable header — always visible */}
<button
onClick={() => setExpanded(e => !e)}
style={{
width: '100%', textAlign: 'left',
padding: '14px 18px', background: '#f9fafb',
borderBottom: expanded ? '1px solid #e5e7eb' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexWrap: 'wrap', gap: 8, cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<ChevronDown
size={16}
style={{
flexShrink: 0, color: '#9ca3af',
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.18s',
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{ fontSize: 22, fontWeight: 800, color: tab.balance > 0 ? '#dc2626' : '#16a34a' }}>
{fmt(tab.balance)}
</span>
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>
{sc.label}
</span>
</div>
</button>
{/* Expanded body */}
{expanded && (
<>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
{[
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
].map(s => (
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
</div>
))}
</div>
{/* Entries */}
{tab.entries.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.entries.map(e => (
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
</div>
))}
</div>
</div>
)}
{/* Payments */}
{tab.payments.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.payments.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>
{p.received_by_name} · {fmtDateTime(p.created_at)}
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}> {p.notes}</span>}
</span>
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
</div>
))}
</div>
</div>
)}
{/* Actions */}
{tab.status === 'open' && (
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{tab.balance > 0.005 && (
<button onClick={() => onPay(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Πληρωμή
</button>
)}
{canClose && (
<button onClick={() => onClose(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Κλείσιμο Καρτέλας
</button>
)}
<button onClick={() => onForgive(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
Χάρισμα Υπολοίπου
</button>
</div>
)}
</>
)}
</div>
)
}
// ── Filter chip ───────────────────────────────────────────────────────────────
function FilterChip({ label, active, onClick }) {
return (
<button
onClick={onClick}
className={`px-3 py-1 rounded-full text-[12px] font-semibold border transition-colors ${
active
? 'bg-slate-800 text-white border-slate-800'
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-400 hover:text-slate-700'
}`}
>
{label}
</button>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function TabsPage() {
const qc = useQueryClient()
const [statusFilter, setStatusFilter] = useState('open') // 'open' | 'closed' | 'all'
const [search, setSearch] = useState('')
const [modal, setModal] = useState(null)
// Fetch open and closed separately — merge for client-side filtering
const { data: openTabs = [], isLoading: loadingOpen } = useQuery({
queryKey: ['tabs-open'],
queryFn: () => client.get('/api/tabs/').then(r => r.data),
staleTime: 15_000,
})
const { data: closedTabs = [], isLoading: loadingClosed } = useQuery({
queryKey: ['tabs-closed'],
queryFn: () => client.get('/api/tabs/', { params: { tab_status: 'closed' } }).then(r => r.data),
staleTime: 15_000,
})
const tabs = [...openTabs, ...closedTabs]
const isLoading = loadingOpen || loadingClosed
const payTab = useMutation({
mutationFn: ({ id, amount, payment_method, notes }) =>
client.post(`/api/tabs/${id}/pay`, { amount, payment_method, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
setModal(null)
toast.success('Πληρωμή καταγράφηκε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
const closeTab = useMutation({
mutationFn: (id) => client.post(`/api/tabs/${id}/close`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
toast.success('Καρτέλα έκλεισε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
const forgiveTab = useMutation({
mutationFn: ({ id, reason }) => client.post(`/api/tabs/${id}/forgive`, { reason }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
setModal(null)
toast.success('Το υπόλοιπο χαρίστηκε')
},
onError: () => toast.error('Σφάλμα'),
})
// Live client-side filtering
const filtered = useMemo(() => {
let result = tabs
if (statusFilter !== 'all') {
result = result.filter(t => {
if (statusFilter === 'open') return t.status === 'open'
if (statusFilter === 'closed') return t.status === 'closed' || t.status === 'forgiven'
return true
})
}
if (search.trim()) {
const q = search.trim().toLowerCase()
result = result.filter(t => t.customer_name?.toLowerCase().includes(q))
}
return result
}, [tabs, statusFilter, search])
const openCount = tabs.filter(t => t.status === 'open').length
const closedCount = tabs.filter(t => t.status === 'closed' || t.status === 'forgiven').length
const totalOutstanding = tabs.filter(t => t.status === 'open').reduce((s, t) => s + t.balance, 0)
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Toolbar */}
<div className="px-6 py-3 border-b border-slate-200 bg-white shrink-0 flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-slate-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Αναζήτηση πελάτη…"
className="pl-8 pr-3 py-1.5 text-[13px] bg-slate-50 border border-slate-200 rounded-lg w-48 focus:outline-none focus:ring-2 focus:ring-sky-500/30 focus:border-sky-400 transition placeholder:text-slate-400"
/>
</div>
{/* Status filter chips */}
<div className="flex items-center gap-2">
<FilterChip label={`Ανοιχτές (${openCount})`} active={statusFilter === 'open'} onClick={() => setStatusFilter('open')} />
<FilterChip label={`Κλειστές (${closedCount})`} active={statusFilter === 'closed'} onClick={() => setStatusFilter('closed')} />
<FilterChip label="Όλες" active={statusFilter === 'all'} onClick={() => setStatusFilter('all')} />
</div>
{/* Summary */}
<div className="ml-auto text-[12px] text-slate-400">
{statusFilter === 'open' && totalOutstanding > 0 && (
<span>Σύνολο: <strong className="text-red-600">{fmt(totalOutstanding)}</strong></span>
)}
</div>
</div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3">
{isLoading && (
<div className="text-center text-slate-400 text-[13px] py-12">Φόρτωση</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="text-center text-slate-400 text-[14px] py-12">
{search ? 'Δεν βρέθηκαν αποτελέσματα.' : 'Δεν υπάρχουν καρτέλες.'}
</div>
)}
{filtered.map(tab => (
<TabDetail
key={tab.id}
tab={tab}
defaultExpanded={false}
onPay={t => setModal({ type: 'pay', tab: t })}
onClose={t => closeTab.mutate(t.id)}
onForgive={t => setModal({ type: 'forgive', tab: t })}
/>
))}
</div>
{modal?.type === 'pay' && (
<PayModal
tab={modal.tab}
onClose={() => setModal(null)}
isPending={payTab.isPending}
onPay={(amount, payment_method, notes) => payTab.mutate({ id: modal.tab.id, amount, payment_method, notes })}
/>
)}
{modal?.type === 'forgive' && (
<ForgiveModal
tab={modal.tab}
onClose={() => setModal(null)}
isPending={forgiveTab.isPending}
onForgive={(reason) => forgiveTab.mutate({ id: modal.tab.id, reason })}
/>
)}
</div>
)
}