Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
739
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
739
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
@@ -0,0 +1,739 @@
|
||||
import { useState } 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'
|
||||
|
||||
// ─── Business Day + Shift Management Panel ───────────────────────────────────
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtShiftDuration(iso) {
|
||||
if (!iso) return ''
|
||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}λ`
|
||||
const h = Math.floor(mins / 60); const m = mins % 60
|
||||
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const hasPendingPayments = details.partially_paid > 0
|
||||
|
||||
if (!hasPendingPayments) {
|
||||
// All tables open but nothing owed — safe to close, just needs confirmation
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Some tables have unpaid items — revenue will be lost, needs hard warning
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function BusinessDayPanel() {
|
||||
const qc = useQueryClient()
|
||||
const [showStartShift, setShowStartShift] = useState(false)
|
||||
const [closeDetails, setCloseDetails] = useState(null)
|
||||
const [forceClosing, setForceClosing] = useState(false)
|
||||
|
||||
const { data: businessDay } = useQuery({
|
||||
queryKey: ['business-day'],
|
||||
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const { data: activeShifts = [] } = useQuery({
|
||||
queryKey: ['active-shifts'],
|
||||
queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const { data: allWaiters = [] } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const waitersWithoutShift = allWaiters.filter(
|
||||
w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
|
||||
)
|
||||
|
||||
const openDayMut = useMutation({
|
||||
mutationFn: () => client.post('/api/business-day/open', {}),
|
||||
onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
|
||||
onError: (e) => toast.error(e.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
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'] })
|
||||
} 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 handleEndShift(shiftId, waiterName) {
|
||||
if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return
|
||||
try {
|
||||
await client.post(`/api/shifts/manager/end/${shiftId}`, {})
|
||||
toast.success('Βάρδια έκλεισε')
|
||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Σφάλμα')
|
||||
}
|
||||
}
|
||||
|
||||
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'] })
|
||||
}
|
||||
|
||||
const isOpen = !!businessDay
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-2xl border overflow-hidden"
|
||||
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-5 py-3"
|
||||
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: isOpen ? '#16a34a' : '#9ca3af',
|
||||
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
|
||||
}} />
|
||||
<div>
|
||||
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
|
||||
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
|
||||
</span>
|
||||
{isOpen && businessDay?.opened_at && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
από {fmtTime(businessDay.opened_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isOpen && waitersWithoutShift.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowStartShift(true)}
|
||||
className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
+ Βάρδια
|
||||
</button>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<button
|
||||
onClick={() => handleCloseDay(false)}
|
||||
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
|
||||
>
|
||||
Κλείσιμο Ημέρας
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openDayMut.mutate()}
|
||||
disabled={openDayMut.isPending}
|
||||
className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
|
||||
>
|
||||
{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active shifts */}
|
||||
{isOpen && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 bg-white">
|
||||
{activeShifts.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeShifts.map(s => (
|
||||
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
|
||||
{s.total_collected > 0 && (
|
||||
<span className="text-xs text-green-700 ml-2 font-medium">€{s.total_collected.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEndShift(s.id, s.waiter_name)}
|
||||
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
|
||||
title="Τέλος βάρδιας"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStartShift && (
|
||||
<StartShiftModal
|
||||
waiters={waitersWithoutShift}
|
||||
onClose={() => setShowStartShift(false)}
|
||||
onStart={handleStartShift}
|
||||
/>
|
||||
)}
|
||||
{closeDetails && (
|
||||
<CloseConfirmModal
|
||||
details={closeDetails}
|
||||
onClose={() => setCloseDetails(null)}
|
||||
onConfirm={() => handleCloseDay(true)}
|
||||
busy={forceClosing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const FILTERS = ['all', 'open', 'partially_paid', 'free']
|
||||
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
||||
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const COLORS = {
|
||||
open: {
|
||||
label: 'Ανοιχτό',
|
||||
tint: '#eef7f0', tintStrong: '#d7ecdc',
|
||||
accent: '#2f9e5e', ink: '#1f7042',
|
||||
},
|
||||
partially_paid: {
|
||||
label: 'Μερική πληρ.',
|
||||
tint: '#f4eefb', tintStrong: '#e3d4f3',
|
||||
accent: '#7a44c9', ink: '#57309a',
|
||||
},
|
||||
free: {
|
||||
label: 'Ελεύθερο',
|
||||
tint: '#f4f4f2', tintStrong: '#dfe2e6',
|
||||
accent: '#8a9099', ink: '#5a6169',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatEuro(n) {
|
||||
return '€' + parseFloat(n).toFixed(2)
|
||||
}
|
||||
|
||||
function formatDuration(openedAt) {
|
||||
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
||||
}
|
||||
|
||||
function occupiedMinsFromDate(openedAt) {
|
||||
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
}
|
||||
|
||||
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 WaiterBubble({ waiter, size = 26 }) {
|
||||
// waiter: { name, avatarUrl }
|
||||
if (waiter.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={waiter.avatarUrl}
|
||||
alt={waiter.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
|
||||
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = waiter.name.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(waiter.name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── V1 Table Card ────────────────────────────────────────────────────────────
|
||||
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, onClick }) {
|
||||
const s = COLORS[status] || COLORS.free
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pressed, setPressed] = useState(false)
|
||||
|
||||
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
|
||||
const showMulti = waiters.length >= 3
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false) }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: '100%', minWidth: 330, height: 200,
|
||||
padding: '16px 18px 16px 24px',
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 14,
|
||||
boxShadow: pressed
|
||||
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
|
||||
: hover
|
||||
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
|
||||
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
|
||||
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* left accent bar */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: '14px 0 0 14px',
|
||||
}} />
|
||||
|
||||
{/* Header: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 34, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: '#111315',
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row */}
|
||||
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{hasPendingPrint && (
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: '#92400e', color: '#fcd34d',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
⏳ Εκκρεμής εκτύπωση
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [retryingId, setRetryingId] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
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 { data: waiters = [] } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => {
|
||||
const name = w.full_name || w.nickname || w.username
|
||||
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
|
||||
const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
|
||||
return [w.id, { name, shortName, avatarUrl }]
|
||||
}))
|
||||
|
||||
const tableCards = tables.map(table => {
|
||||
const order = orders.find(o =>
|
||||
o.table_id === table.id && ['open', 'partially_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)
|
||||
|
||||
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}`)
|
||||
}
|
||||
queryClient.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 filtered = filter === 'all'
|
||||
? tableCards
|
||||
: tableCards.filter(c => c.tableStatus === filter)
|
||||
|
||||
if (tablesLoading || ordersLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BusinessDayPanel />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Dashboard</h1>
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{filtered.map(({ table, order, tableStatus, hasPendingPrint }) => {
|
||||
const waiterNames = order
|
||||
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
|
||||
: []
|
||||
const amount = order ? orderTotal(order.items) : null
|
||||
|
||||
return (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
hasPendingPrint={hasPendingPrint}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
|
||||
{pendingPrintOrders.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span style={{ fontSize: 20 }}>⏳</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
|
||||
<p className="text-xs text-orange-700 mt-0.5">
|
||||
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary text-sm"
|
||||
style={{ background: '#c2410c', borderColor: '#c2410c' }}
|
||||
onClick={retryAllOrders}
|
||||
disabled={retryingId !== null}
|
||||
>
|
||||
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-orange-50">
|
||||
{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} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
|
||||
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
|
||||
{tableName}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
className="btn btn-secondary text-xs"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary text-xs"
|
||||
style={{ background: '#c2410c', borderColor: '#c2410c' }}
|
||||
onClick={() => retrySingleOrder(order.id)}
|
||||
disabled={retryingId === order.id}
|
||||
>
|
||||
{retryingId === order.id ? '…' : 'Εκτύπωση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user