- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal, PaymentMethodModal; updated Sidebar routing and App navigation - Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals - Backend routers: extended orders, reports, shifts, products, business_day endpoints; updated cloud_sync service - Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1600 lines
74 KiB
JavaScript
1600 lines
74 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import toast from 'react-hot-toast'
|
||
import client from '../api/client'
|
||
import Badge from '../ui/Badge'
|
||
import { ConfirmModal } from '../ui/Modal'
|
||
import { LicenseContext } from '../layouts/AppLayout'
|
||
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
|
||
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function fmtTime(iso) {
|
||
if (!iso) return '—'
|
||
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
function fmtDuration(isoStart) {
|
||
if (!isoStart) return '—'
|
||
const mins = Math.floor((Date.now() - new Date(isoStart).getTime()) / 60000)
|
||
if (mins < 60) return `${mins}λ`
|
||
const h = Math.floor(mins / 60)
|
||
const m = mins % 60
|
||
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
|
||
}
|
||
|
||
function fmtEuro(n) {
|
||
return '€' + parseFloat(n || 0).toFixed(2)
|
||
}
|
||
|
||
function orderTotal(items = []) {
|
||
return items
|
||
.filter(i => i.status !== 'cancelled')
|
||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||
}
|
||
|
||
function avatarColor(name) {
|
||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
|
||
let h = 0
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||
return palette[h % palette.length]
|
||
}
|
||
|
||
function Avatar({ name, avatarUrl, size = 36 }) {
|
||
if (avatarUrl) {
|
||
return (
|
||
<img src={avatarUrl} alt={name}
|
||
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||
)
|
||
}
|
||
const parts = (name || '?').trim().split(' ')
|
||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||
return (
|
||
<div style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: avatarColor(name || '?'),
|
||
color: 'white', fontSize: size * 0.38, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||
}}>{initials}</div>
|
||
)
|
||
}
|
||
|
||
// ─── Table color tokens ────────────────────────────────────────────────────────
|
||
|
||
const TABLE_COLORS = {
|
||
open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' },
|
||
partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' },
|
||
free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
|
||
}
|
||
|
||
// ─── Business Day / Shift modals ───────────────────────────────────────────────
|
||
|
||
function StartShiftModal({ waiters, onClose, onStart }) {
|
||
const [waiterId, setWaiterId] = useState('')
|
||
const [cash, setCash] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
async function submit() {
|
||
if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return }
|
||
setBusy(true)
|
||
try {
|
||
await onStart(Number(waiterId), cash ? parseFloat(cash) : null)
|
||
onClose()
|
||
} catch (e) {
|
||
toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-bold text-gray-800">Έναρξη Βάρδιας</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
|
||
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
|
||
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
|
||
<option value="">— Επιλέξτε —</option>
|
||
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά (€)</label>
|
||
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => setCash(e.target.value)}
|
||
className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
|
||
</div>
|
||
<div className="flex gap-3 pt-1">
|
||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
|
||
<button onClick={submit} disabled={busy}
|
||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CloseConfirmModal({ details, onClose, onConfirm, busy }) {
|
||
if (!details.partially_paid) {
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
|
||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
|
||
<p className="font-semibold">
|
||
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
|
||
</p>
|
||
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
|
||
<button onClick={onConfirm} disabled={busy}
|
||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||
<span className="text-red-600 text-lg font-bold">!</span>
|
||
</div>
|
||
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
|
||
</div>
|
||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
|
||
<p className="font-semibold">
|
||
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
|
||
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
|
||
</p>
|
||
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
|
||
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
|
||
<button onClick={onConfirm} disabled={busy}
|
||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
||
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Order quick-view modal ────────────────────────────────────────────────────
|
||
|
||
function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
||
const qc = useQueryClient()
|
||
|
||
const { data: order, isLoading } = useQuery({
|
||
queryKey: ['order', orderId],
|
||
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
|
||
enabled: !!orderId,
|
||
})
|
||
|
||
const { data: waiters = [] } = useQuery({
|
||
queryKey: ['waiters'],
|
||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const { data: printers = [] } = useQuery({
|
||
queryKey: ['printers'],
|
||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const [confirmAction, setConfirmAction] = useState(null)
|
||
const [printerId, setPrinterId] = useState('')
|
||
|
||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
|
||
|
||
const invalidate = () => {
|
||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||
}
|
||
|
||
const payItems = useMutation({
|
||
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
|
||
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
|
||
onError: () => toast.error('Σφάλμα πληρωμής'),
|
||
})
|
||
|
||
const cancelItem = useMutation({
|
||
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
|
||
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
|
||
onError: () => toast.error('Σφάλμα ακύρωσης'),
|
||
})
|
||
|
||
const cancelOrder = useMutation({
|
||
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
||
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
|
||
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
||
})
|
||
|
||
const closeOrder = useMutation({
|
||
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
|
||
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); invalidate(); onClose() },
|
||
onError: () => toast.error('Σφάλμα κλεισίματος'),
|
||
})
|
||
|
||
const printOrder = useMutation({
|
||
mutationFn: (pid) => client.post(`/api/orders/${orderId}/print`, { printer_id: pid }),
|
||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||
})
|
||
|
||
function handleConfirm() {
|
||
if (!confirmAction) return
|
||
if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload)
|
||
if (confirmAction.type === 'cancelOrder') cancelOrder.mutate()
|
||
if (confirmAction.type === 'closeOrder') closeOrder.mutate()
|
||
setConfirmAction(null)
|
||
}
|
||
|
||
const total = order ? orderTotal(order.items) : 0
|
||
const activeItems = order ? order.items.filter(i => i.status === 'active') : []
|
||
const isOpen = order ? ['open', 'partially_paid'].includes(order.status) : false
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||
>
|
||
<div style={{
|
||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 720,
|
||
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Modal header */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '18px 22px', borderBottom: '1px solid #edeff1', flexShrink: 0,
|
||
}}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, color: '#111315' }}>
|
||
Τραπέζι {tableName}
|
||
</div>
|
||
{order && (
|
||
<div style={{ fontSize: 12, color: '#5a6169', marginTop: 2 }}>
|
||
Παραγγελία #{order.id} · από {fmtTime(order.opened_at)} · {fmtDuration(order.opened_at)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{order && (
|
||
<span style={{
|
||
fontSize: 18, fontWeight: 700,
|
||
color: '#111315',
|
||
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||
}}>{fmtEuro(total)}</span>
|
||
)}
|
||
{order && (
|
||
<Badge status={order.status} />
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
width: 32, height: 32, borderRadius: 8, border: '1px solid #dfe2e6',
|
||
background: 'white', cursor: 'pointer', fontSize: 16, color: '#5a6169',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
>✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scrollable body */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 0 8px' }}>
|
||
{isLoading && (
|
||
<div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Φόρτωση…</div>
|
||
)}
|
||
|
||
{order && (
|
||
<>
|
||
{/* Waiters row */}
|
||
{order.waiters.length > 0 && (
|
||
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f4f4f2', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontSize: 12, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Προσωπικό</span>
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
{order.waiters.map(w => (
|
||
<span key={w.waiter_id} style={{
|
||
padding: '3px 10px', borderRadius: 999,
|
||
background: '#f0f4ff', color: '#3758c9',
|
||
fontSize: 12, fontWeight: 600,
|
||
}}>{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Items list */}
|
||
<div>
|
||
{order.items.length === 0 && (
|
||
<div style={{ padding: '24px', textAlign: 'center', color: '#8a9099', fontSize: 13 }}>Κανένα αντικείμενο.</div>
|
||
)}
|
||
{order.items.map(item => (
|
||
<div key={item.id} style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
padding: '10px 22px',
|
||
borderBottom: '1px solid #f9f9f8',
|
||
opacity: item.status === 'cancelled' ? 0.4 : 1,
|
||
textDecoration: item.status === 'cancelled' ? 'line-through' : 'none',
|
||
}}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>
|
||
{item.product?.name ?? `#${item.product_id}`}
|
||
<span style={{ color: '#8a9099', fontWeight: 400, marginLeft: 6 }}>×{item.quantity}</span>
|
||
</div>
|
||
{item.notes && <div style={{ fontSize: 11, color: '#8a9099', marginTop: 1 }}>{item.notes}</div>}
|
||
{item.paid_by && (
|
||
<div style={{ fontSize: 11, color: '#2f9e5e', marginTop: 1, fontWeight: 600 }}>
|
||
Πληρώθηκε
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||
<span style={{
|
||
fontSize: 14, fontWeight: 700, color: '#111315',
|
||
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||
minWidth: 52, textAlign: 'right',
|
||
}}>{fmtEuro(item.unit_price * item.quantity)}</span>
|
||
{isOpen && item.status === 'active' && (
|
||
<>
|
||
<button
|
||
onClick={() => payItems.mutate([item.id])}
|
||
style={{
|
||
height: 28, padding: '0 10px', borderRadius: 6,
|
||
border: '1px solid #d7ecdc', background: '#eef7f0',
|
||
color: '#1f7042', fontSize: 11, fontWeight: 700, cursor: 'pointer',
|
||
}}
|
||
>Πληρωμή</button>
|
||
<button
|
||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||
style={{
|
||
height: 28, padding: '0 10px', borderRadius: 6,
|
||
border: '1px solid #fecaca', background: '#fef2f2',
|
||
color: '#dc2626', fontSize: 11, fontWeight: 700, cursor: 'pointer',
|
||
}}
|
||
>✕</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer actions */}
|
||
{order && (
|
||
<div style={{
|
||
padding: '14px 22px', borderTop: '1px solid #edeff1',
|
||
display: 'flex', gap: 8, flexWrap: 'nowrap', flexShrink: 0,
|
||
background: '#fafafa', alignItems: 'center',
|
||
}}>
|
||
{isOpen && activeItems.length > 0 && (
|
||
<button
|
||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||
style={{
|
||
height: 36, padding: '0 16px', borderRadius: 8,
|
||
background: '#3758c9', border: 'none', color: 'white',
|
||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||
}}
|
||
>Πληρωμή όλων</button>
|
||
)}
|
||
{isOpen && (
|
||
<>
|
||
<button
|
||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||
style={{
|
||
height: 36, padding: '0 14px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>Κλείσιμο</button>
|
||
<button
|
||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||
style={{
|
||
height: 36, padding: '0 14px', borderRadius: 8,
|
||
border: '1px solid #fecaca', background: '#fef2f2',
|
||
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>Ακύρωση</button>
|
||
</>
|
||
)}
|
||
{printers.length > 0 && (
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
|
||
<select
|
||
value={printerId}
|
||
onChange={e => setPrinterId(e.target.value)}
|
||
style={{
|
||
height: 36, paddingLeft: 10, paddingRight: 28, borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#2b2f33', fontSize: 12, cursor: 'pointer',
|
||
}}
|
||
>
|
||
<option value="">Εκτυπωτής…</option>
|
||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
<button
|
||
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
|
||
disabled={!printerId}
|
||
style={{
|
||
height: 36, padding: '0 14px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#2b2f33', fontSize: 13, fontWeight: 600,
|
||
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
|
||
}}
|
||
>🖨 Εκτύπωση</button>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={onOpenFull}
|
||
style={{
|
||
height: 36, padding: '0 14px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||
marginLeft: printers.length > 0 ? 0 : 'auto',
|
||
}}
|
||
>Πλήρης εικόνα →</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{confirmAction && (
|
||
<ConfirmModal
|
||
title={
|
||
confirmAction.type === 'cancelItem' ? 'Ακύρωση αντικειμένου;' :
|
||
confirmAction.type === 'cancelOrder' ? 'Ακύρωση παραγγελίας;' :
|
||
'Κλείσιμο παραγγελίας;'
|
||
}
|
||
message={confirmAction.type === 'cancelOrder' ? 'Η παραγγελία θα ακυρωθεί οριστικά.' : undefined}
|
||
confirmLabel={confirmAction.type === 'closeOrder' ? 'Κλείσιμο' : 'Ακύρωση'}
|
||
confirmVariant={confirmAction.type === 'closeOrder' ? 'primary' : 'danger'}
|
||
onConfirm={handleConfirm}
|
||
onCancel={() => setConfirmAction(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── KPI Card ─────────────────────────────────────────────────────────────────
|
||
|
||
function KpiCard({ label, value, sub, accent = '#3758c9', pct, private: isPrivate }) {
|
||
return (
|
||
<div style={{
|
||
background: 'white',
|
||
border: '1px solid #edeff1',
|
||
borderRadius: 16,
|
||
padding: '20px 22px',
|
||
display: 'flex', flexDirection: 'column', gap: 6,
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
flex: 1,
|
||
}}>
|
||
<div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.7 }}>{label}</div>
|
||
<div style={{
|
||
fontSize: 30, fontWeight: 700, color: '#111315', letterSpacing: -0.5, lineHeight: 1.1,
|
||
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||
filter: isPrivate ? 'blur(8px)' : 'none',
|
||
userSelect: isPrivate ? 'none' : 'auto',
|
||
transition: 'filter 200ms ease',
|
||
}}>{value}</div>
|
||
{sub && <div style={{
|
||
fontSize: 12, color: '#5a6169',
|
||
filter: isPrivate ? 'blur(6px)' : 'none',
|
||
userSelect: isPrivate ? 'none' : 'auto',
|
||
transition: 'filter 200ms ease',
|
||
}}>{sub}</div>}
|
||
{pct != null && (
|
||
<div style={{ marginTop: 4, height: 6, borderRadius: 3, background: '#edeff1', overflow: 'hidden' }}>
|
||
<div style={{ width: `${Math.min(100, pct)}%`, height: '100%', background: accent, borderRadius: 3, transition: 'width 300ms ease' }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Mini table chip ──────────────────────────────────────────────────────────
|
||
|
||
function TableChip({ name, status, amount, onClick }) {
|
||
const c = TABLE_COLORS[status] || TABLE_COLORS.free
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
title={amount != null ? fmtEuro(amount) : name}
|
||
style={{
|
||
aspectRatio: '1', minHeight: 52,
|
||
borderRadius: 10,
|
||
background: c.bg,
|
||
border: '1px solid ' + c.border,
|
||
color: c.text,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 12, fontWeight: 700,
|
||
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||
cursor: onClick ? 'pointer' : 'default',
|
||
transition: 'transform 100ms ease, box-shadow 100ms ease',
|
||
}}
|
||
onMouseEnter={e => { if (onClick) { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)' } }}
|
||
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '' }}
|
||
>
|
||
{name}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ─── Shifts card ──────────────────────────────────────────────────────────────
|
||
|
||
// ─── End shift confirmation modal ─────────────────────────────────────────────
|
||
|
||
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
||
const [notes, setNotes] = useState('')
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||
<span style={{ fontSize: 18 }}>⏹</span>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας;</div>
|
||
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>{shift.waiter_name}</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ background: '#fafafa', borderRadius: 12, padding: '12px 16px', fontSize: 13, color: '#374151' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||
<span>Έναρξη</span><span style={{ fontWeight: 600 }}>{fmtTime(shift.started_at)}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||
<span>Διάρκεια</span><span style={{ fontWeight: 600 }}>{fmtDuration(shift.started_at)}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
||
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
||
</label>
|
||
<textarea
|
||
value={notes}
|
||
onChange={e => setNotes(e.target.value)}
|
||
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
|
||
rows={2}
|
||
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
|
||
onFocus={e => e.target.style.borderColor = '#6366f1'}
|
||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||
/>
|
||
</div>
|
||
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
|
||
<div className="flex gap-3">
|
||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
|
||
<button onClick={() => onConfirm(notes)} disabled={busy}
|
||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
||
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Compose for single waiter (quick message from shift row) ─────────────────
|
||
|
||
function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
|
||
const [body, setBody] = useState('')
|
||
const sendMut = useMutation({
|
||
mutationFn: (payload) => client.post('/api/messages/send', payload),
|
||
onSuccess: () => { toast.success('Εστάλη!'); onSent(); onClose() },
|
||
onError: () => toast.error('Σφάλμα αποστολής'),
|
||
})
|
||
function send() {
|
||
if (!body.trim()) return
|
||
sendMut.mutate({ body: body.trim(), target_waiter_ids: [waiter.id], table_ids: [] })
|
||
}
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-5 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>💬 Μήνυμα σε {waiter.name}</div>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#8a9099' }}>✕</button>
|
||
</div>
|
||
{templates.length > 0 && (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{templates.map(t => (
|
||
<button key={t.id} onClick={() => setBody(t.body)} style={{
|
||
height: 26, padding: '0 10px', borderRadius: 6,
|
||
border: `1px solid ${body === t.body ? '#3758c9' : '#dfe2e6'}`,
|
||
background: body === t.body ? '#eff3ff' : '#f9fafb',
|
||
color: body === t.body ? '#3758c9' : '#374151',
|
||
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit',
|
||
}}>{t.body}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<textarea
|
||
placeholder="Μήνυμα…"
|
||
value={body}
|
||
onChange={e => setBody(e.target.value)}
|
||
rows={3}
|
||
style={{ width: '100%', borderRadius: 10, border: '1.5px solid #dfe2e6', padding: '10px 14px', fontSize: 14, fontFamily: 'inherit', resize: 'vertical', outline: 'none', boxSizing: 'border-box', color: '#111315' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||
<button onClick={onClose} style={{ height: 36, padding: '0 16px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||
<button onClick={send} disabled={!body.trim() || sendMut.isPending}
|
||
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: !body.trim() ? 0.5 : 1 }}>
|
||
{sendMut.isPending ? 'Αποστολή…' : '📤 Αποστολή'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter, privacyMode }) {
|
||
return (
|
||
<div style={{
|
||
background: 'white', border: '1px solid #edeff1',
|
||
borderRadius: 16, overflow: 'hidden',
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '16px 20px', borderBottom: '1px solid #edeff1',
|
||
}}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Βάρδιες σε εξέλιξη</div>
|
||
{isOpen && waitersWithoutShift.length > 0 && (
|
||
<button
|
||
onClick={onStartShift}
|
||
style={{
|
||
height: 30, padding: '0 12px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#2b2f33', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>+ Έναρξη</button>
|
||
)}
|
||
</div>
|
||
<div style={{ padding: '12px 20px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{activeShifts.length === 0 ? (
|
||
<p style={{ fontSize: 13, color: '#8a9099', padding: '8px 0' }}>Κανένας σερβιτόρος σε βάρδια</p>
|
||
) : (
|
||
activeShifts.map(s => {
|
||
const activeBreak = Array.isArray(s.breaks) ? s.breaks.find(b => !b.ended_at) : null
|
||
return (
|
||
<div key={s.id} style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
padding: '10px 12px', borderRadius: 12,
|
||
border: activeBreak ? '1px solid #fed7aa' : '1px solid #edeff1',
|
||
background: activeBreak ? '#fff7ed' : '#fafafa',
|
||
transition: 'background 200ms, border-color 200ms',
|
||
}}>
|
||
<Avatar name={s.waiter_name} avatarUrl={s.avatar_url ?? null} size={36} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{s.waiter_name}</span>
|
||
{activeBreak && (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
padding: '2px 8px', borderRadius: 999,
|
||
background: '#fed7aa', color: '#9a3412',
|
||
fontSize: 11, fontWeight: 700,
|
||
}}>
|
||
☕ Διάλειμμα · {fmtDuration(activeBreak.started_at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 1 }}>
|
||
από {fmtTime(s.started_at)} · {fmtDuration(s.started_at)}
|
||
{s.total_collected > 0 && (
|
||
<span style={{
|
||
color: '#2f9e5e', fontWeight: 600, marginLeft: 6,
|
||
filter: privacyMode ? 'blur(6px)' : 'none',
|
||
userSelect: privacyMode ? 'none' : 'auto',
|
||
transition: 'filter 200ms ease',
|
||
}}>{fmtEuro(s.total_collected)}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => onMessageWaiter(s)}
|
||
title="Αποστολή μηνύματος"
|
||
style={{
|
||
height: 28, width: 28, borderRadius: 8, flexShrink: 0,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 14, cursor: 'pointer',
|
||
}}
|
||
>💬</button>
|
||
<button
|
||
onClick={() => onEndShift(s)}
|
||
style={{
|
||
height: 28, padding: '0 10px', borderRadius: 8,
|
||
border: '1px solid #fecaca', background: '#fef2f2',
|
||
color: '#dc2626', fontSize: 11, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
|
||
}}
|
||
>Τέλος</button>
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Revenue bar chart ─────────────────────────────────────────────────────────
|
||
|
||
function RevenueChart({ orders }) {
|
||
const now = new Date()
|
||
const currentHour = now.getHours()
|
||
const buckets = {}
|
||
for (let h = 10; h <= 23; h++) buckets[h] = 0
|
||
orders.forEach(o => {
|
||
const h = new Date(o.opened_at).getHours()
|
||
if (h >= 10 && h <= 23) buckets[h] = (buckets[h] || 0) + orderTotal(o.items)
|
||
})
|
||
const hours = Object.keys(buckets).map(Number)
|
||
const max = Math.max(...Object.values(buckets), 1)
|
||
|
||
return (
|
||
<div style={{
|
||
background: 'white', border: '1px solid #edeff1',
|
||
borderRadius: 16, padding: '16px 20px',
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
}}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', marginBottom: 16 }}>Έσοδα ανά ώρα</div>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 90 }}>
|
||
{hours.map(h => {
|
||
const val = buckets[h]
|
||
const heightPct = (val / max) * 100
|
||
const isCurrent = h === currentHour
|
||
const isFuture = h > currentHour
|
||
return (
|
||
<div key={h} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, height: '100%' }}>
|
||
<div style={{ flex: 1, width: '100%', display: 'flex', alignItems: 'flex-end' }}>
|
||
<div title={fmtEuro(val)} style={{
|
||
width: '100%', height: val > 0 ? `${heightPct}%` : '3px',
|
||
minHeight: val > 0 ? 4 : 3, borderRadius: 4,
|
||
background: isCurrent ? '#3758c9' : isFuture ? '#edeff1' : '#c2cff0',
|
||
transition: 'height 300ms ease',
|
||
}} />
|
||
</div>
|
||
<div style={{
|
||
fontSize: 9, fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||
color: isCurrent ? '#3758c9' : '#8a9099', fontWeight: isCurrent ? 700 : 500,
|
||
}}>{h}</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Reservations stub ────────────────────────────────────────────────────────
|
||
|
||
function ReservationsCard() {
|
||
return (
|
||
<div style={{
|
||
background: 'white', border: '1px solid #edeff1',
|
||
borderRadius: 16, overflow: 'hidden',
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '16px 20px', borderBottom: '1px solid #edeff1',
|
||
}}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Κρατήσεις σήμερα</div>
|
||
<button style={{
|
||
height: 28, padding: '0 12px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||
}}>+ Νέα</button>
|
||
</div>
|
||
<div style={{
|
||
padding: '32px 20px', textAlign: 'center',
|
||
color: '#b8bdc4', fontSize: 13,
|
||
}}>
|
||
<div style={{ fontSize: 28, marginBottom: 8 }}>📅</div>
|
||
Σύντομα — σύστημα κρατήσεων υπό ανάπτυξη
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Messages card ────────────────────────────────────────────────────────────
|
||
|
||
function ComposeModal({ waiters, tables, templates, onClose, onSent }) {
|
||
const [selectedWaiters, setSelectedWaiters] = useState([]) // [] = all
|
||
const [selectedTables, setSelectedTables] = useState([])
|
||
const [body, setBody] = useState('')
|
||
|
||
const sendMut = useMutation({
|
||
mutationFn: (payload) => client.post('/api/messages/send', payload),
|
||
onSuccess: () => { toast.success('Εστάλη!'); onSent(); onClose() },
|
||
onError: () => toast.error('Σφάλμα αποστολής'),
|
||
})
|
||
|
||
function toggleWaiter(id) {
|
||
setSelectedWaiters(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||
}
|
||
function toggleTable(id) {
|
||
setSelectedTables(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||
}
|
||
|
||
function send() {
|
||
if (!body.trim()) return
|
||
sendMut.mutate({
|
||
body: body.trim(),
|
||
target_waiter_ids: selectedWaiters, // [] = broadcast all
|
||
table_ids: selectedTables,
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||
}} onClick={onClose}>
|
||
<div style={{
|
||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 520,
|
||
padding: 24, boxShadow: '0 24px 64px rgba(0,0,0,0.22)',
|
||
}} onClick={e => e.stopPropagation()}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Νέο Μήνυμα</div>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#8a9099' }}>✕</button>
|
||
</div>
|
||
|
||
{/* Recipients */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
|
||
Παραλήπτες {selectedWaiters.length === 0 && <span style={{ color: '#3758c9', fontWeight: 700 }}>(Όλοι)</span>}
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{waiters.map(w => {
|
||
const name = w.nickname || w.full_name || w.username
|
||
const sel = selectedWaiters.includes(w.id)
|
||
return (
|
||
<button key={w.id} onClick={() => toggleWaiter(w.id)} style={{
|
||
height: 30, padding: '0 12px', borderRadius: 999,
|
||
border: '1.5px solid ' + (sel ? '#3758c9' : '#dfe2e6'),
|
||
background: sel ? '#eff3ff' : 'white',
|
||
color: sel ? '#3758c9' : '#374151',
|
||
fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
|
||
}}>{name}</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tables (optional) */}
|
||
{tables.length > 0 && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
|
||
Σχετικά Τραπέζια (προαιρετικό)
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 80, overflowY: 'auto' }}>
|
||
{tables.map(t => {
|
||
const name = t.label || `T${t.number}`
|
||
const sel = selectedTables.includes(t.id)
|
||
return (
|
||
<button key={t.id} onClick={() => toggleTable(t.id)} style={{
|
||
height: 28, padding: '0 10px', borderRadius: 6,
|
||
border: '1.5px solid ' + (sel ? '#7a44c9' : '#dfe2e6'),
|
||
background: sel ? '#f5eeff' : 'white',
|
||
color: sel ? '#7a44c9' : '#374151',
|
||
fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
|
||
}}>{name}</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick templates */}
|
||
{templates.length > 0 && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>Γρήγορα Μηνύματα</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{templates.map(t => (
|
||
<button key={t.id} onClick={() => setBody(t.body)} style={{
|
||
height: 28, padding: '0 12px', borderRadius: 6,
|
||
border: '1px solid ' + (body === t.body ? '#3758c9' : '#dfe2e6'),
|
||
background: body === t.body ? '#eff3ff' : '#f9fafb',
|
||
color: body === t.body ? '#3758c9' : '#374151',
|
||
fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
|
||
}}>{t.body}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom body */}
|
||
<textarea
|
||
placeholder="Ή γράψτε το δικό σας μήνυμα…"
|
||
value={body}
|
||
onChange={e => setBody(e.target.value)}
|
||
rows={3}
|
||
style={{
|
||
width: '100%', borderRadius: 10, border: '1.5px solid #dfe2e6',
|
||
padding: '10px 14px', fontSize: 14, fontFamily: 'inherit',
|
||
resize: 'vertical', outline: 'none', boxSizing: 'border-box',
|
||
color: '#111315',
|
||
}}
|
||
/>
|
||
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 16, justifyContent: 'flex-end' }}>
|
||
<button onClick={onClose} style={{ height: 38, padding: '0 18px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||
<button
|
||
onClick={send}
|
||
disabled={!body.trim() || sendMut.isPending}
|
||
style={{ height: 38, padding: '0 20px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: !body.trim() ? 0.5 : 1 }}
|
||
>{sendMut.isPending ? 'Αποστολή…' : '📤 Αποστολή'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MessagesCard({ waiters, tables }) {
|
||
const qc = useQueryClient()
|
||
const [composing, setComposing] = useState(false)
|
||
|
||
const { data: messages = [] } = useQuery({
|
||
queryKey: ['messages-all'],
|
||
queryFn: () => client.get('/api/messages/all?limit=20').then(r => r.data),
|
||
refetchInterval: 10_000,
|
||
})
|
||
|
||
const { data: templates = [] } = useQuery({
|
||
queryKey: ['quick-templates'],
|
||
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
function parseJsonField(val) {
|
||
if (Array.isArray(val)) return val
|
||
try { return JSON.parse(val || '[]') } catch { return [] }
|
||
}
|
||
|
||
function fmtTargets(msg) {
|
||
const ids = parseJsonField(msg.target_waiter_ids)
|
||
if (ids.length === 0) return 'Όλοι'
|
||
return ids.map(id => {
|
||
const w = waiters.find(w => w.id === id)
|
||
return w ? (w.nickname || w.full_name || w.username) : `#${id}`
|
||
}).join(', ')
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{composing && (
|
||
<ComposeModal
|
||
waiters={waiters}
|
||
tables={tables}
|
||
templates={templates}
|
||
onClose={() => setComposing(false)}
|
||
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
|
||
/>
|
||
)}
|
||
<div style={{
|
||
background: 'white', border: '1px solid #edeff1',
|
||
borderRadius: 16, overflow: 'hidden',
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '16px 20px', borderBottom: '1px solid #edeff1',
|
||
}}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Μηνύματα</div>
|
||
<button
|
||
onClick={() => setComposing(true)}
|
||
style={{
|
||
height: 28, padding: '0 12px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>+ Νέο</button>
|
||
</div>
|
||
|
||
{messages.length === 0 ? (
|
||
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
|
||
<div style={{ fontSize: 28, marginBottom: 8 }}>💬</div>
|
||
Δεν υπάρχουν μηνύματα ακόμα
|
||
</div>
|
||
) : (
|
||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||
{messages.map(msg => {
|
||
const ackedIds = parseJsonField(msg.acked_by).length
|
||
const totalTargets = parseJsonField(msg.target_waiter_ids).length || waiters.length
|
||
return (
|
||
<div key={msg.id} style={{
|
||
padding: '10px 20px', borderBottom: '1px solid #f4f4f2',
|
||
display: 'flex', gap: 12, alignItems: 'flex-start',
|
||
}}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', marginBottom: 2 }}>{msg.body}</div>
|
||
<div style={{ fontSize: 11, color: '#8a9099' }}>
|
||
Προς: {fmtTargets(msg)} · {new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: 700,
|
||
color: ackedIds >= totalTargets ? '#16a34a' : '#f59e0b',
|
||
background: ackedIds >= totalTargets ? '#f0fdf4' : '#fffbeb',
|
||
border: `1px solid ${ackedIds >= totalTargets ? '#bbf7d0' : '#fde68a'}`,
|
||
borderRadius: 999, padding: '2px 8px', whiteSpace: 'nowrap', flexShrink: 0,
|
||
}}>
|
||
{ackedIds}/{totalTargets} ✓
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Pending prints panel ──────────────────────────────────────────────────────
|
||
|
||
function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onViewOrder, retryingId }) {
|
||
if (pendingPrintOrders.length === 0) return null
|
||
return (
|
||
<div style={{
|
||
background: 'white', borderRadius: 16, overflow: 'hidden',
|
||
border: '1px solid #fed7aa', boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '14px 20px', background: '#fff7ed', borderBottom: '1px solid #fed7aa',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{ fontSize: 18 }}>⏳</span>
|
||
<div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#9a3412' }}>Εκκρεμείς Εκτυπώσεις</div>
|
||
<div style={{ fontSize: 12, color: '#c2410c', marginTop: 1 }}>
|
||
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onRetryAll} disabled={retryingId !== null}
|
||
style={{
|
||
height: 32, padding: '0 14px', borderRadius: 8,
|
||
background: '#c2410c', border: 'none', color: 'white',
|
||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}</button>
|
||
</div>
|
||
{pendingPrintOrders.map(({ table, order }) => {
|
||
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
|
||
const tableName = table.label || `T${table.number}`
|
||
return (
|
||
<div key={order.id} style={{
|
||
display: 'flex', alignItems: 'center', gap: 14,
|
||
padding: '10px 20px', borderBottom: '1px solid #fff7ed',
|
||
}}>
|
||
<div style={{
|
||
width: 36, height: 36, borderRadius: 8, flexShrink: 0,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontWeight: 700, fontSize: 12, background: '#fff7ed', color: '#c2410c',
|
||
border: '1px solid #fed7aa', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||
}}>{tableName}</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
|
||
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
|
||
</div>
|
||
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||
<button onClick={() => onViewOrder(order.id)} className="btn btn-secondary text-xs">Λεπτομέρειες</button>
|
||
<button
|
||
onClick={() => onRetrySingle(order.id)} disabled={retryingId === order.id}
|
||
style={{
|
||
height: 28, padding: '0 10px', borderRadius: 6,
|
||
background: '#c2410c', border: 'none', color: 'white',
|
||
fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>{retryingId === order.id ? '…' : 'Εκτύπωση'}</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main page ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function DashboardPage() {
|
||
const [showStartShift, setShowStartShift] = useState(false)
|
||
const [closeDetails, setCloseDetails] = useState(null)
|
||
const [forceClosing, setForceClosing] = useState(false)
|
||
const [retryingId, setRetryingId] = useState(null)
|
||
const [quickView, setQuickView] = useState(null)
|
||
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
|
||
// End shift flow: confirm modal → summary modal
|
||
const [endShiftTarget, setEndShiftTarget] = useState(null) // shift object to confirm
|
||
const [endShiftBusy, setEndShiftBusy] = useState(false)
|
||
const [shiftSummaryId, setShiftSummaryId] = useState(null) // show summary for this shift id
|
||
// Quick message to single waiter
|
||
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
|
||
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
|
||
const navigate = useNavigate()
|
||
const qc = useQueryClient()
|
||
const license = useContext(LicenseContext)
|
||
|
||
const { data: businessDay } = useQuery({
|
||
queryKey: ['business-day'],
|
||
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
|
||
refetchInterval: 10_000,
|
||
})
|
||
|
||
const { data: activeShifts = [] } = useQuery({
|
||
queryKey: ['active-shifts'],
|
||
queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
|
||
refetchInterval: 10_000,
|
||
})
|
||
|
||
const { data: allWaiters = [] } = useQuery({
|
||
queryKey: ['waiters'],
|
||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const { data: quickTemplates = [] } = useQuery({
|
||
queryKey: ['quick-templates'],
|
||
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const { data: tables = [], isLoading: tablesLoading } = useQuery({
|
||
queryKey: ['tables'],
|
||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||
refetchInterval: 5_000,
|
||
})
|
||
|
||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||
queryKey: ['orders-active'],
|
||
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
||
refetchInterval: 5_000,
|
||
})
|
||
|
||
const openDayMut = useMutation({
|
||
mutationFn: () => client.post('/api/business-day/open', {}),
|
||
onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
|
||
onError: (e) => {
|
||
const detail = e.response?.data?.detail
|
||
if (detail?.code === 'SYSTEM_LOCKED' || detail?.code === 'LICENSE_EXPIRED') {
|
||
setLicenseBlock({ code: detail.code, message: detail.message })
|
||
} else {
|
||
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα')
|
||
}
|
||
},
|
||
})
|
||
|
||
function handleOpenDay() {
|
||
if (license?.isBlocked) {
|
||
setLicenseBlock({
|
||
code: license.lock_reason === 'admin' ? 'SYSTEM_LOCKED' : 'LICENSE_EXPIRED',
|
||
message: license.lock_reason === 'admin'
|
||
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
|
||
: 'Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.',
|
||
})
|
||
return
|
||
}
|
||
openDayMut.mutate()
|
||
}
|
||
|
||
async function handleCloseDay(force = false) {
|
||
setForceClosing(force)
|
||
try {
|
||
await client.post('/api/business-day/close', { force })
|
||
toast.success('Ημέρα έκλεισε!')
|
||
setCloseDetails(null)
|
||
qc.invalidateQueries({ queryKey: ['business-day'] })
|
||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||
qc.invalidateQueries({ queryKey: ['tables'] })
|
||
qc.invalidateQueries({ queryKey: ['messages-all'] })
|
||
} catch (e) {
|
||
const detail = e.response?.data?.detail
|
||
if (e.response?.status === 409 && detail?.open_orders) setCloseDetails(detail)
|
||
else toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
|
||
} finally {
|
||
setForceClosing(false)
|
||
}
|
||
}
|
||
|
||
async function handleEndShiftConfirm(notes) {
|
||
if (!endShiftTarget) return
|
||
setEndShiftBusy(true)
|
||
try {
|
||
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
|
||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
|
||
setEndShiftTarget(null)
|
||
setShiftSummaryId(summaryTarget)
|
||
} catch (e) {
|
||
toast.error(e.response?.data?.detail || 'Σφάλμα')
|
||
} finally {
|
||
setEndShiftBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleStartShift(waiterId, startingCash) {
|
||
await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash })
|
||
toast.success('Βάρδια ξεκίνησε!')
|
||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||
}
|
||
|
||
async function retrySingleOrder(orderId) {
|
||
setRetryingId(orderId)
|
||
try {
|
||
const res = await client.post(`/api/orders/${orderId}/retry-print`)
|
||
const results = res.data.print_results ?? []
|
||
const allOk = results.length === 0 || results.every(r => r.success)
|
||
if (allOk) toast.success('Εκτυπώθηκε επιτυχώς')
|
||
else {
|
||
const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
|
||
toast.error(`Αποτυχία: ${failed}`)
|
||
}
|
||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||
} catch {
|
||
toast.error('Σφάλμα επικοινωνίας')
|
||
} finally {
|
||
setRetryingId(null)
|
||
}
|
||
}
|
||
|
||
async function retryAllOrders() {
|
||
for (const { order } of pendingPrintOrders) {
|
||
if (order) await retrySingleOrder(order.id)
|
||
}
|
||
}
|
||
|
||
const isOpen = !!businessDay
|
||
|
||
const waitersWithoutShift = allWaiters.filter(
|
||
w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
|
||
)
|
||
|
||
// Build table cards
|
||
const tableCards = tables.map(table => {
|
||
const order = orders.find(o =>
|
||
o.table_id === table.id && ['open', 'partially_paid', 'paid'].includes(o.status)
|
||
)
|
||
const tableStatus = order ? order.status : 'free'
|
||
const hasPendingPrint = order ? order.items.some(i => i.status === 'active' && !i.printed) : false
|
||
return { table, order, tableStatus, hasPendingPrint }
|
||
})
|
||
|
||
const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
|
||
|
||
// KPIs — revenue scoped to orders that opened after business day start
|
||
const businessDayStart = businessDay?.opened_at ? new Date(businessDay.opened_at) : null
|
||
const dayOrders = businessDayStart
|
||
? orders.filter(o => new Date(o.opened_at) >= businessDayStart)
|
||
: orders
|
||
|
||
const totalRevenue = dayOrders.reduce((s, o) => s + orderTotal(o.items), 0)
|
||
const openCount = tableCards.filter(c => c.tableStatus === 'open').length
|
||
const partialCount = tableCards.filter(c => c.tableStatus === 'partially_paid').length
|
||
const occupiedCount = openCount + partialCount
|
||
const freeCount = tableCards.filter(c => c.tableStatus === 'free').length
|
||
|
||
// Avg ticket: revenue divided by number of orders with at least one paid item
|
||
const paidOrders = dayOrders.filter(o => o.items.some(i => i.paid_by))
|
||
const avgTicket = paidOrders.length > 0 ? totalRevenue / paidOrders.length : 0
|
||
|
||
// Total collected by all active shifts
|
||
const totalCollected = activeShifts.reduce((s, sh) => s + (sh.total_collected || 0), 0)
|
||
|
||
const todayStr = new Date().toLocaleDateString('el-GR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||
|
||
if (tablesLoading || ordersLoading) {
|
||
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-y-auto h-full p-6" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
|
||
{/* ── Header bar ─────────────────────────────────────────────────────── */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
padding: '4px 10px', borderRadius: 999,
|
||
background: isOpen ? '#eef7f0' : '#f4f4f2',
|
||
color: isOpen ? '#1f7042' : '#5a6169',
|
||
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5,
|
||
}}>
|
||
<span style={{
|
||
width: 6, height: 6, borderRadius: 3,
|
||
background: isOpen ? '#2f9e5e' : '#8a9099',
|
||
boxShadow: isOpen ? '0 0 0 2px #bbf7d0' : 'none',
|
||
}} />
|
||
{isOpen ? 'Ημέρα Ανοιχτή' : 'Ημέρα Κλειστή'}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 3 }}>
|
||
{todayStr}
|
||
{isOpen && businessDay?.opened_at && ` · από ${fmtTime(businessDay.opened_at)} · ${fmtDuration(businessDay.opened_at)}`}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<button
|
||
onClick={() => setPrivacyMode(p => { const next = !p; localStorage.setItem('privacyMode', next); return next })}
|
||
title={privacyMode ? 'Εμφάνιση ποσών' : 'Απόκρυψη ποσών'}
|
||
style={{
|
||
height: 38, width: 38, borderRadius: 10, flexShrink: 0,
|
||
border: '1px solid #dfe2e6',
|
||
background: privacyMode ? '#1e293b' : 'white',
|
||
color: privacyMode ? '#94a3b8' : '#5a6169',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: 16, transition: 'background 200ms, color 200ms',
|
||
}}
|
||
>
|
||
{privacyMode ? (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||
</svg>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{isOpen ? (
|
||
<button onClick={() => handleCloseDay(false)} style={{
|
||
height: 38, padding: '0 18px', borderRadius: 10,
|
||
background: '#dc2626', border: 'none', color: 'white',
|
||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||
}}>Κλείσιμο Ημέρας</button>
|
||
) : (
|
||
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
|
||
height: 38, padding: '0 18px', borderRadius: 10,
|
||
background: '#16a34a', border: 'none', color: 'white',
|
||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||
}}>{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── KPI strip — full-width flex row ────────────────────────────────── */}
|
||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||
<KpiCard
|
||
label="Έσοδα ημέρας"
|
||
value={fmtEuro(totalRevenue)}
|
||
sub={`${dayOrders.length} παραγγελί${dayOrders.length !== 1 ? 'ες' : 'α'} · μ.ο. ${fmtEuro(avgTicket)}`}
|
||
accent="#3758c9"
|
||
private={privacyMode}
|
||
/>
|
||
<KpiCard
|
||
label="Τραπέζια σε χρήση"
|
||
value={`${occupiedCount} / ${tables.length}`}
|
||
sub={`${freeCount} ελεύθερα · ${partialCount} μερική πληρ.`}
|
||
accent="#7a44c9"
|
||
pct={tables.length > 0 ? (occupiedCount / tables.length) * 100 : 0}
|
||
/>
|
||
<KpiCard
|
||
label="Βάρδιες ενεργές"
|
||
value={String(activeShifts.length)}
|
||
sub={activeShifts.length > 0 ? activeShifts.map(s => s.waiter_name.split(' ')[0]).join(', ') : 'Κανένας σε βάρδια'}
|
||
accent="#2f9e5e"
|
||
/>
|
||
<KpiCard
|
||
label="Είσπραξη βαρδιών"
|
||
value={fmtEuro(totalCollected)}
|
||
sub={`Σύνολο από ${activeShifts.length} βάρδι${activeShifts.length === 1 ? 'α' : 'ες'}`}
|
||
accent="#0d7a8a"
|
||
private={privacyMode}
|
||
/>
|
||
{pendingPrintOrders.length > 0 && (
|
||
<KpiCard
|
||
label="Εκκρεμείς εκτυπώσεις"
|
||
value={String(pendingPrintOrders.length)}
|
||
sub="Απαιτείται προσοχή"
|
||
accent="#d94b26"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Main 2-column layout: 65 / 35 ──────────────────────────────────── */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '65fr 35fr', gap: 20, alignItems: 'start' }}>
|
||
|
||
{/* ── Left column — Shifts + Tables + Revenue + Pending prints ───────── */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
|
||
{/* Shifts card — FIRST */}
|
||
<ShiftsCard
|
||
activeShifts={activeShifts}
|
||
waitersWithoutShift={waitersWithoutShift}
|
||
isOpen={isOpen}
|
||
onStartShift={() => setShowStartShift(true)}
|
||
onEndShift={(shift) => setEndShiftTarget(shift)}
|
||
onMessageWaiter={(s) => setMessageWaiter({ id: s.waiter_id, name: s.waiter_name })}
|
||
privacyMode={privacyMode}
|
||
/>
|
||
|
||
{/* Tables overview — SECOND */}
|
||
<div style={{
|
||
background: 'white', border: '1px solid #edeff1',
|
||
borderRadius: 16, overflow: 'hidden',
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '16px 20px', borderBottom: '1px solid #edeff1',
|
||
}}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Τραπέζια τώρα</div>
|
||
<button
|
||
onClick={() => navigate('/tables')}
|
||
style={{
|
||
height: 28, padding: '0 12px', borderRadius: 8,
|
||
border: '1px solid #dfe2e6', background: 'white',
|
||
color: '#2b2f33', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
>Πλήρης εικόνα →</button>
|
||
</div>
|
||
<div style={{ padding: '16px 20px' }}>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))',
|
||
gap: 8, marginBottom: 14,
|
||
}}>
|
||
{tableCards.map(({ table, tableStatus, order }) => (
|
||
<TableChip
|
||
key={table.id}
|
||
name={table.label || `T${table.number}`}
|
||
status={tableStatus}
|
||
amount={order ? orderTotal(order.items) : null}
|
||
onClick={order ? () => setQuickView({ orderId: order.id, tableName: table.label || `T${table.number}` }) : undefined}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
{Object.entries(TABLE_COLORS).map(([key, c]) => (
|
||
<div key={key} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
padding: '4px 10px', borderRadius: 999, background: '#edeff1',
|
||
fontSize: 12, fontWeight: 600, color: '#2b2f33',
|
||
}}>
|
||
<span style={{ width: 8, height: 8, borderRadius: 4, background: c.dot }} />
|
||
{c.label}
|
||
<span style={{ fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace", color: '#111315' }}>
|
||
{tableCards.filter(tc => tc.tableStatus === key).length}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Revenue chart */}
|
||
<RevenueChart orders={dayOrders} />
|
||
|
||
{/* Pending prints */}
|
||
<PendingPrintsPanel
|
||
pendingPrintOrders={pendingPrintOrders}
|
||
onRetryAll={retryAllOrders}
|
||
onRetrySingle={retrySingleOrder}
|
||
onViewOrder={id => setQuickView({ orderId: id, tableName: tables.find(t => tableCards.find(tc => tc.order?.id === id)?.table.id === t.id)?.label || '—' })}
|
||
retryingId={retryingId}
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Right column — Reservations + Messages ───────────────────────── */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
<ReservationsCard />
|
||
<MessagesCard waiters={allWaiters} tables={tables} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
||
{showStartShift && (
|
||
<StartShiftModal
|
||
waiters={waitersWithoutShift}
|
||
onClose={() => setShowStartShift(false)}
|
||
onStart={handleStartShift}
|
||
/>
|
||
)}
|
||
|
||
{endShiftTarget && (
|
||
<EndShiftConfirmModal
|
||
shift={endShiftTarget}
|
||
onClose={() => setEndShiftTarget(null)}
|
||
onConfirm={handleEndShiftConfirm}
|
||
busy={endShiftBusy}
|
||
/>
|
||
)}
|
||
|
||
{shiftSummaryId && (
|
||
<ShiftDetailModal
|
||
shiftId={shiftSummaryId.id}
|
||
shiftWaiterId={shiftSummaryId.waiter_id}
|
||
onClose={() => setShiftSummaryId(null)}
|
||
/>
|
||
)}
|
||
|
||
{messageWaiter && (
|
||
<QuickMessageModal
|
||
waiter={messageWaiter}
|
||
tables={tables}
|
||
templates={quickTemplates}
|
||
onClose={() => setMessageWaiter(null)}
|
||
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
|
||
/>
|
||
)}
|
||
{closeDetails && (
|
||
<CloseConfirmModal
|
||
details={closeDetails}
|
||
onClose={() => setCloseDetails(null)}
|
||
onConfirm={() => handleCloseDay(true)}
|
||
busy={forceClosing}
|
||
/>
|
||
)}
|
||
{quickView && (
|
||
<OrderQuickModal
|
||
orderId={quickView.orderId}
|
||
tableName={quickView.tableName}
|
||
onClose={() => setQuickView(null)}
|
||
onOpenFull={() => { navigate(`/orders/${quickView.orderId}`); setQuickView(null) }}
|
||
/>
|
||
)}
|
||
{licenseBlock && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm text-center space-y-4">
|
||
<div className="flex justify-center">
|
||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||
licenseBlock.code === 'SYSTEM_LOCKED' ? 'bg-red-100' : 'bg-orange-100'
|
||
}`}>
|
||
<span className="text-2xl">{licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'}</span>
|
||
</div>
|
||
</div>
|
||
<h2 className="text-[15px] font-bold text-slate-900">
|
||
{licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'}
|
||
</h2>
|
||
<p className="text-[13px] text-slate-600">{licenseBlock.message}</p>
|
||
<button
|
||
onClick={() => setLicenseBlock(null)}
|
||
className="w-full h-10 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[13px] font-semibold transition-colors"
|
||
>
|
||
Κλείσιμο
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|