Manager Dashboard: product reorder/bulk actions, preference sub-choices UI, expanded reports with DateInput component, waiter management updates, order detail improvements, Docker config and backend dockerignore added. Backend: table groups, auto-numbering, has_active_order flag, expanded reporting endpoints, waiter zone management, user schema updates, system router additions, table router fixes. Waiter PWA: TableDetailPage order/payment event improvements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
937 lines
47 KiB
JavaScript
937 lines
47 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||
import toast from 'react-hot-toast'
|
||
import client from '../api/client'
|
||
import StatusBadge from '../components/StatusBadge'
|
||
import { DateTimeInput } from '../components/DateInput'
|
||
|
||
function today() {
|
||
return new Date().toISOString().slice(0, 10)
|
||
}
|
||
function todayStart() { return today() + 'T00:00' }
|
||
function todayEnd() { return today() + 'T23:59' }
|
||
|
||
function fmtDt(dt) {
|
||
if (!dt) return '—'
|
||
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||
}
|
||
|
||
function csvDownload(rows, filename) {
|
||
const header = Object.keys(rows[0]).join(',')
|
||
const body = rows.map(r => Object.values(r).join(',')).join('\n')
|
||
const blob = new Blob([header + '\n' + body], { type: 'text/csv' })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = filename
|
||
a.click()
|
||
}
|
||
|
||
// ── Shared label class for consistent toolbar height ─────────────────────────
|
||
// All toolbar controls use h-10 so they align with date inputs
|
||
|
||
const CTRL = 'h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500'
|
||
const SELECT = CTRL + ' pr-8 appearance-none bg-[url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="%236b7280"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>\')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat'
|
||
const BTN_SEC = 'h-10 px-4 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors whitespace-nowrap'
|
||
const BTN_PRI = 'h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors whitespace-nowrap'
|
||
|
||
// ── Shared modal close hook (ESC + click-outside) ────────────────────────────
|
||
|
||
function useModalClose(onClose) {
|
||
const overlayRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||
document.addEventListener('keydown', onKey)
|
||
return () => document.removeEventListener('keydown', onKey)
|
||
}, [onClose])
|
||
|
||
const onOverlayClick = useCallback((e) => {
|
||
if (e.target === overlayRef.current) onClose()
|
||
}, [onClose])
|
||
|
||
return { overlayRef, onOverlayClick }
|
||
}
|
||
|
||
// ── Print Modal (waiter / printer reports) ────────────────────────────────────
|
||
|
||
function PrintModal({ title, onClose, onPrint, printers, defaultFrom, defaultTo }) {
|
||
const [mode, setMode] = useState('simple')
|
||
const [fromDt, setFromDt] = useState(defaultFrom || todayStart())
|
||
const [toDt, setToDt] = useState(defaultTo || todayEnd())
|
||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
|
||
function submit() {
|
||
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||
onPrint({ mode, fromDt, toDt, printerId: Number(printerId) })
|
||
onClose()
|
||
}
|
||
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Τύπος εκτύπωσης</p>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setMode('simple')} className={`flex-1 ${mode === 'simple' ? BTN_PRI : BTN_SEC}`}>Απλή</button>
|
||
<button onClick={() => setMode('extensive')} className={`flex-1 ${mode === 'extensive' ? BTN_PRI : BTN_SEC}`}>Αναλυτική</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||
<DateTimeInput className={CTRL + ' w-full'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||
<DateTimeInput className={CTRL + ' w-full'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Εκτυπωτής</label>
|
||
<select className={SELECT + ' w-full'} value={printerId} onChange={e => setPrinterId(e.target.value)}>
|
||
<option value="">— Επιλέξτε —</option>
|
||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="flex gap-3 pt-1">
|
||
<button onClick={onClose} className={`flex-1 ${BTN_SEC}`}>Ακύρωση</button>
|
||
<button onClick={submit} className={`flex-1 ${BTN_PRI}`}>Εκτύπωση</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Printer select modal (single order) ───────────────────────────────────────
|
||
|
||
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
function submit() {
|
||
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||
onPrint(Number(printerId)); onClose()
|
||
}
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
|
||
<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 leading-none">✕</button>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Εκτυπωτής</label>
|
||
<select className={SELECT + ' w-full'} value={printerId} onChange={e => setPrinterId(e.target.value)}>
|
||
<option value="">— Επιλέξτε —</option>
|
||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button onClick={onClose} className={`flex-1 ${BTN_SEC}`}>Ακύρωση</button>
|
||
<button onClick={submit} className={`flex-1 ${BTN_PRI}`}>Εκτύπωση</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── No-printers fallback modal ────────────────────────────────────────────────
|
||
|
||
function NoPrintersModal({ onClose }) {
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
|
||
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
|
||
<button onClick={onClose} className={`w-full ${BTN_SEC}`}>Κλείσιμο</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// ── Printer Details Modal ─────────────────────────────────────────────────────
|
||
|
||
function PrinterDetailsModal({ printerRow, tableMap, onClose }) {
|
||
const orderData = printerRow?.order_data ?? []
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h2 className="text-lg font-bold text-gray-800">Εκτυπωτής: {printerRow?.printer_name}</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||
</div>
|
||
<div className="flex gap-6 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
|
||
<span><span className="text-gray-500">Εργασίες:</span> <strong>{printerRow?.print_jobs}</strong></span>
|
||
<span><span className="text-gray-500">Παραγγελίες:</span> <strong>{printerRow?.orders}</strong></span>
|
||
<span><span className="text-gray-500">Αντικείμενα:</span> <strong>{printerRow?.items}</strong></span>
|
||
<span><span className="text-gray-500">Σύνολο:</span> <strong className="text-primary-700">€{printerRow?.total?.toFixed(2)}</strong></span>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1">
|
||
{orderData.length === 0 && (
|
||
<p className="text-gray-400 p-6 text-center">Δεν υπάρχουν αναλυτικά δεδομένα.<br/><span className="text-xs">Χρησιμοποιήστε εκτύπωση Αναλυτική για πλήρη λεπτομέρεια.</span></p>
|
||
)}
|
||
{orderData.length > 0 && (
|
||
<div className="divide-y divide-gray-100">
|
||
{orderData.map((od, i) => (
|
||
<div key={i} className="px-4 py-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-semibold text-gray-800">
|
||
{od.time} — {tableMap[od.table_id] ?? od.table}
|
||
</span>
|
||
<span className="font-semibold text-primary-700">€{od.total.toFixed(2)}</span>
|
||
</div>
|
||
{od.items?.length > 0 && (
|
||
<ul className="mt-1 space-y-0.5">
|
||
{od.items.map((item, j) => (
|
||
<li key={j} className="text-sm text-gray-600 pl-4">
|
||
{item.quantity} × {item.name}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="px-6 py-4 border-t border-gray-100">
|
||
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Payers Modal (multi-waiter payment breakdown) ────────────────────────────
|
||
|
||
function PayersModal({ order, waiterMap, onClose }) {
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
const paidItems = order.items.filter(i => i.status === 'paid' && i.paid_by)
|
||
|
||
// Group items by (waiter_id, paid_at rounded to minute) to create payment groups
|
||
const groups = {}
|
||
for (const item of paidItems) {
|
||
const key = `${item.paid_by}__${item.paid_at}`
|
||
if (!groups[key]) {
|
||
groups[key] = { waiter_id: item.paid_by, paid_at: item.paid_at, payment_method: item.payment_method, items: [] }
|
||
}
|
||
groups[key].items.push(item)
|
||
}
|
||
|
||
const paymentGroups = Object.values(groups).sort((a, b) => (a.paid_at || '') < (b.paid_at || '') ? -1 : 1)
|
||
|
||
const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—'
|
||
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h2 className="text-lg font-bold text-gray-800">Πληρωμές — Παραγγελία #{order.id}</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
|
||
{paymentGroups.map((g, i) => {
|
||
const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0)
|
||
const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}`
|
||
return (
|
||
<div key={i} className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<span className="font-semibold text-gray-800">{fmtDt(g.paid_at)}</span>
|
||
<span className="mx-2 text-gray-400">·</span>
|
||
<span className="text-primary-700 font-medium">{waiterName}</span>
|
||
{g.payment_method && (
|
||
<span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{pmLabel(g.payment_method)}</span>
|
||
)}
|
||
</div>
|
||
<span className="font-bold text-gray-800">€{groupTotal.toFixed(2)}</span>
|
||
</div>
|
||
<ul className="pl-4 space-y-0.5">
|
||
{g.items.map(it => (
|
||
<li key={it.id} className="flex justify-between text-sm text-gray-600">
|
||
<span>{it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
|
||
<span>€{(it.unit_price * it.quantity).toFixed(2)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="px-6 py-4 border-t border-gray-100">
|
||
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Order Details Modal ───────────────────────────────────────────────────────
|
||
|
||
function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPrint }) {
|
||
const [showPrint, setShowPrint] = useState(false)
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
if (!order) return null
|
||
|
||
const activeItems = order.items.filter(i => i.status !== 'cancelled')
|
||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||
|
||
// Per-waiter subtotals for paid items
|
||
const waiterTotals = {}
|
||
for (const item of activeItems.filter(i => i.status === 'paid' && i.paid_by)) {
|
||
const wid = item.paid_by
|
||
if (!waiterTotals[wid]) waiterTotals[wid] = 0
|
||
waiterTotals[wid] += item.unit_price * item.quantity
|
||
}
|
||
const waiterTotalEntries = Object.entries(waiterTotals)
|
||
|
||
const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—'
|
||
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h2 className="text-lg font-bold text-gray-800">Παραγγελία #{order.id}</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-6 gap-y-1 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
|
||
<span><span className="text-gray-500">Τραπέζι:</span> <strong>{tableMap[order.table_id] || `#${order.table_id}`}</strong></span>
|
||
<span><span className="text-gray-500">Ανοίχτηκε:</span> <strong>{fmtDt(order.opened_at)}</strong></span>
|
||
<span><span className="text-gray-500">Έκλεισε:</span> <strong>{fmtDt(order.closed_at)}</strong></span>
|
||
<span><StatusBadge status={order.status} /></span>
|
||
{order.notes && <span className="w-full text-gray-500 italic">"{order.notes}"</span>}
|
||
</div>
|
||
<div className="overflow-y-auto flex-1">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-100 sticky top-0">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Προϊόν</th>
|
||
<th className="text-center px-4 py-3 font-semibold text-gray-600">Ποσ.</th>
|
||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Τιμή/τμχ</th>
|
||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατ.</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Πληρώθηκε</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τύπος</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{order.items.map(item => (
|
||
<tr key={item.id} className={item.status === 'cancelled' ? 'opacity-40 line-through' : ''}>
|
||
<td className="px-4 py-2.5 text-gray-800">{item.product?.name ?? `#${item.product_id}`}</td>
|
||
<td className="px-4 py-2.5 text-center text-gray-700">{item.quantity}</td>
|
||
<td className="px-4 py-2.5 text-right text-gray-700">€{item.unit_price.toFixed(2)}</td>
|
||
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">€{(item.unit_price * item.quantity).toFixed(2)}</td>
|
||
<td className="px-4 py-2.5"><StatusBadge status={item.status} /></td>
|
||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{item.paid_at ? fmtDt(item.paid_at) : '—'}</td>
|
||
<td className="px-4 py-2.5 text-gray-500">{item.payment_method ? pmLabel(item.payment_method) : '—'}</td>
|
||
<td className="px-4 py-2.5 text-gray-600">{item.paid_by ? (waiterMap[item.paid_by] || `#${item.paid_by}`) : '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||
{waiterTotalEntries.length > 1 && waiterTotalEntries.map(([wid, wTotal]) => (
|
||
<tr key={wid} className="text-sm">
|
||
<td colSpan={3} className="px-4 py-2 text-gray-600">Σύνολο — {waiterMap[wid] || `#${wid}`}</td>
|
||
<td className="px-4 py-2 text-right text-gray-700">€{wTotal.toFixed(2)}</td>
|
||
<td colSpan={4} />
|
||
</tr>
|
||
))}
|
||
<tr>
|
||
<td colSpan={3} className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||
<td className="px-4 py-3 text-right font-bold text-primary-700">€{total.toFixed(2)}</td>
|
||
<td colSpan={4} />
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
<div className="px-6 py-4 border-t border-gray-100 flex gap-3">
|
||
{printers.length > 0 && (
|
||
<button onClick={() => setShowPrint(true)} className={BTN_SEC}>🖨 Εκτύπωση</button>
|
||
)}
|
||
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||
</div>
|
||
</div>
|
||
{showPrint && (
|
||
<PrintOrderModal
|
||
printers={printers}
|
||
onClose={() => setShowPrint(false)}
|
||
onPrint={(printerId) => { onPrint(order.id, printerId); setShowPrint(false) }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function ReportsPage() {
|
||
const [tab, setTab] = useState('shift')
|
||
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
|
||
|
||
const TABS = [
|
||
['shift', 'Σύνοψη Πληρωμών Βάρδιας'],
|
||
['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'],
|
||
['printers', 'Σύνοψη εκτυπωτών'],
|
||
['history', 'Ιστορικό παραγγελιών'],
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="text-xl font-bold text-gray-800">Αναφορές</h1>
|
||
|
||
<div className="flex gap-2 flex-wrap">
|
||
{TABS.map(([key, label]) => (
|
||
<button key={key} onClick={() => setTab(key)} className={`btn ${tab === key ? 'btn-primary' : 'btn-secondary'}`}>{label}</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
||
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
||
{tab === 'printers' && <PrintersTab />}
|
||
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Shift / Waiter Totals Tab ─────────────────────────────────────────────────
|
||
|
||
function WaiterShiftDetailsModal({ row, tableMap, onClose }) {
|
||
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||
return (
|
||
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h2 className="text-lg font-bold text-gray-800">Σερβιτόρος: {row.waiter_name}</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||
</div>
|
||
<div className="flex gap-6 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
|
||
<span><span className="text-gray-500">Παραγγελίες:</span> <strong>{row.orders}</strong></span>
|
||
<span><span className="text-gray-500">Αντικείμενα:</span> <strong>{row.items}</strong></span>
|
||
<span><span className="text-gray-500">Σύνολο:</span> <strong className="text-primary-700">€{row.total.toFixed(2)}</strong></span>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1">
|
||
{(row.order_data ?? []).length === 0 && (
|
||
<p className="text-gray-400 p-6 text-center">Δεν βρέθηκαν παραγγελίες.</p>
|
||
)}
|
||
{(row.order_data ?? []).length > 0 && (
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-100 sticky top-0">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Άνοιγμα</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κλείσιμο</th>
|
||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{row.order_data.map(od => (
|
||
<tr key={od.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-2 text-gray-500">{od.id}</td>
|
||
<td className="px-4 py-2 font-medium text-gray-800">{od.table}</td>
|
||
<td className="px-4 py-2 text-gray-600">{od.time_open}</td>
|
||
<td className="px-4 py-2 text-gray-600">{od.time_close || '—'}</td>
|
||
<td className="px-4 py-2 text-right font-semibold text-gray-800">€{od.total.toFixed(2)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
<div className="px-6 py-4 border-t border-gray-100">
|
||
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ShiftTab({ endpoint, title }) {
|
||
const [fromDt, setFromDt] = useState(todayStart())
|
||
const [toDt, setToDt] = useState(todayEnd())
|
||
const [printTarget, setPrintTarget] = useState(null) // waiter row
|
||
const [detailTarget, setDetailTarget] = useState(null) // waiter row
|
||
|
||
const { data, isLoading, refetch } = useQuery({
|
||
queryKey: ['report-shift', endpoint, fromDt, toDt],
|
||
queryFn: () => client.get(`${endpoint}?from=${encodeURIComponent(fromDt)}&to=${encodeURIComponent(toDt)}`).then(r => r.data),
|
||
})
|
||
|
||
const { data: printers = [] } = useQuery({
|
||
queryKey: ['printers'],
|
||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const { data: tables = [] } = useQuery({
|
||
queryKey: ['tables'],
|
||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
|
||
|
||
const printMutation = useMutation({
|
||
mutationFn: (body) => client.post('/api/reports/print/waiter', body),
|
||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||
})
|
||
|
||
// New API returns { waiters: [{waiter_id, waiter_name, orders, items, total, order_data}] }
|
||
const rows = data?.waiters ?? []
|
||
|
||
const grandTotal = rows.reduce((s, r) => s + r.total, 0)
|
||
const grandOrders = rows.reduce((s, r) => s + r.orders, 0)
|
||
const grandItems = rows.reduce((s, r) => s + r.items, 0)
|
||
|
||
function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) {
|
||
printMutation.mutate({ waiter_name: printTarget.waiter_name, printer_id: printerId, mode, from_dt: fd, to_dt: td })
|
||
}
|
||
|
||
const csvRows = rows.map(r => ({
|
||
Σερβιτόρος: r.waiter_name,
|
||
Παραγγελίες: r.orders,
|
||
Αντικείμενα: r.items,
|
||
'Σύνολο (€)': r.total.toFixed(2),
|
||
}))
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Toolbar */}
|
||
<div className="flex items-end gap-3 flex-wrap">
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||
</div>
|
||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||
{rows.length > 0 && (
|
||
<button onClick={() => csvDownload(csvRows, `shift_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
|
||
Εξαγωγή CSV
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||
{!isLoading && rows.length === 0 && (
|
||
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.</p>
|
||
)}
|
||
|
||
{rows.length > 0 && (
|
||
<div className="card overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-100">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Αντικείμενα</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σύνολο (€)</th>
|
||
<th className="px-4 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{rows.map((r, i) => (
|
||
<tr key={i} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 font-medium text-gray-800">{r.waiter_name}</td>
|
||
<td className="px-4 py-3 text-gray-700">{r.orders}</td>
|
||
<td className="px-4 py-3 text-gray-700">{r.items}</td>
|
||
<td className="px-4 py-3 text-gray-800">€{r.total.toFixed(2)}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex gap-2 justify-end">
|
||
<button onClick={() => setDetailTarget(r)} className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7">Λεπτομέρειες</button>
|
||
<button onClick={() => setPrintTarget(r)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">🖨 Εκτύπωση</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||
<tr>
|
||
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||
<td className="px-4 py-3 font-bold">{grandOrders}</td>
|
||
<td className="px-4 py-3 font-bold">{grandItems}</td>
|
||
<td className="px-4 py-3 font-bold text-primary-700">€{grandTotal.toFixed(2)}</td>
|
||
<td />
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{printTarget && printers.length > 0 && (
|
||
<PrintModal
|
||
title={`Εκτύπωση: ${printTarget.waiter_name}`}
|
||
printers={printers}
|
||
defaultFrom={fromDt}
|
||
defaultTo={toDt}
|
||
onClose={() => setPrintTarget(null)}
|
||
onPrint={handlePrint}
|
||
/>
|
||
)}
|
||
{printTarget && printers.length === 0 && <NoPrintersModal onClose={() => setPrintTarget(null)} />}
|
||
|
||
{detailTarget && (
|
||
<WaiterShiftDetailsModal
|
||
row={detailTarget}
|
||
tableMap={tableMap}
|
||
onClose={() => setDetailTarget(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Printer Totals Tab ────────────────────────────────────────────────────────
|
||
|
||
function PrintersTab() {
|
||
const [fromDt, setFromDt] = useState(todayStart())
|
||
const [toDt, setToDt] = useState(todayEnd())
|
||
const [printTarget, setPrintTarget] = useState(null) // printer_id
|
||
const [detailTarget, setDetailTarget] = useState(null) // full printer row object
|
||
|
||
const params = new URLSearchParams({ from: fromDt, to: toDt })
|
||
|
||
const { data, isLoading, refetch } = useQuery({
|
||
queryKey: ['report-printers', fromDt, toDt],
|
||
queryFn: () => client.get(`/api/reports/printers?${params}`).then(r => r.data),
|
||
})
|
||
|
||
const { data: printers = [] } = useQuery({
|
||
queryKey: ['printers'],
|
||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const { data: tables = [] } = useQuery({
|
||
queryKey: ['tables'],
|
||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
|
||
|
||
const printMutation = useMutation({
|
||
mutationFn: (body) => client.post('/api/reports/print/printer', body),
|
||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||
})
|
||
|
||
const rows = data?.printers ?? []
|
||
|
||
function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) {
|
||
printMutation.mutate({ printer_target_id: printTarget, printer_id: printerId, mode, from_dt: fd, to_dt: td })
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Toolbar */}
|
||
<div className="flex items-end gap-3 flex-wrap">
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||
</div>
|
||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||
</div>
|
||
|
||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||
{!isLoading && rows.length === 0 && (
|
||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν δεδομένα για αυτό το διάστημα.</p>
|
||
)}
|
||
|
||
{rows.length > 0 && (
|
||
<div className="card overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-100">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Εκτυπωτής</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Εργασίες</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Αντικείμενα</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σύνολο (€)</th>
|
||
<th className="px-4 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{rows.map((r, i) => (
|
||
<tr key={i} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 font-medium text-gray-800">{r.printer_name}</td>
|
||
<td className="px-4 py-3 text-gray-700">{r.print_jobs}</td>
|
||
<td className="px-4 py-3 text-gray-700">{r.orders}</td>
|
||
<td className="px-4 py-3 text-gray-700">{r.items}</td>
|
||
<td className="px-4 py-3 font-semibold text-gray-800">€{r.total.toFixed(2)}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex gap-2 justify-end">
|
||
<button onClick={() => setDetailTarget(r)} className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7">Λεπτομέρειες</button>
|
||
<button onClick={() => setPrintTarget(r.printer_id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">🖨 Εκτύπωση</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||
<tr>
|
||
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.print_jobs, 0)}</td>
|
||
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.orders, 0)}</td>
|
||
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.items, 0)}</td>
|
||
<td className="px-4 py-3 font-bold text-primary-700">€{rows.reduce((s, r) => s + r.total, 0).toFixed(2)}</td>
|
||
<td />
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{printTarget !== null && printers.length > 0 && (
|
||
<PrintModal
|
||
title={`Εκτύπωση: ${rows.find(r => r.printer_id === printTarget)?.printer_name ?? 'Εκτυπωτής'}`}
|
||
printers={printers}
|
||
defaultFrom={fromDt}
|
||
defaultTo={toDt}
|
||
onClose={() => setPrintTarget(null)}
|
||
onPrint={handlePrint}
|
||
/>
|
||
)}
|
||
|
||
{detailTarget && (
|
||
<PrinterDetailsModal
|
||
printerRow={detailTarget}
|
||
tableMap={tableMap}
|
||
onClose={() => setDetailTarget(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Order History Tab ─────────────────────────────────────────────────────────
|
||
|
||
function HistoryTab({ filters, setFilters }) {
|
||
const [page, setPage] = useState(1)
|
||
const [detailOrder, setDetailOrder] = useState(null) // full order object for modal
|
||
const [printOrderId, setPrintOrderId] = useState(null)
|
||
const [payersModal, setPayersModal] = useState(null) // order object for multi-payer modal
|
||
|
||
const { data: tables = [] } = useQuery({
|
||
queryKey: ['tables'],
|
||
queryFn: () => client.get('/api/tables/').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 { data: waiters = [] } = useQuery({
|
||
queryKey: ['waiters'],
|
||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
|
||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.full_name || w.username]))
|
||
|
||
const params = new URLSearchParams({ from: filters.from, to: filters.to, page })
|
||
if (filters.status) params.set('status', filters.status)
|
||
if (filters.table_id) params.set('table_id', filters.table_id)
|
||
|
||
const { data: orders = [], isLoading } = useQuery({
|
||
queryKey: ['order-history', filters, page],
|
||
queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data),
|
||
})
|
||
|
||
const printMutation = useMutation({
|
||
mutationFn: ({ orderId, printerId }) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
|
||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||
})
|
||
|
||
function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) }
|
||
|
||
const visibleOrders = filters.hideEmpty
|
||
? orders.filter(o => o.items.some(i => i.status !== 'cancelled'))
|
||
: orders
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Toolbar */}
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||
<DateTimeInput className={CTRL + ' w-52'} value={filters.from} onChange={e => setF('from', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||
<DateTimeInput className={CTRL + ' w-52'} value={filters.to} onChange={e => setF('to', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Κατάσταση</label>
|
||
<select className={SELECT + ' w-44'} value={filters.status} onChange={e => setF('status', e.target.value)}>
|
||
<option value="">Όλες</option>
|
||
<option value="open">Ανοιχτές</option>
|
||
<option value="partially_paid">Μερική πληρωμή</option>
|
||
<option value="closed">Κλειστές</option>
|
||
<option value="cancelled">Ακυρωμένες</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Τραπέζι</label>
|
||
<select className={SELECT + ' w-44'} value={filters.table_id} onChange={e => setF('table_id', e.target.value)}>
|
||
<option value="">Όλα</option>
|
||
{tables.map(t => (
|
||
<option key={t.id} value={t.id}>{t.label || `T${t.number}`}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<label className="flex items-center gap-2 h-10 cursor-pointer select-none text-sm text-gray-700">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 rounded accent-primary-700"
|
||
checked={filters.hideEmpty}
|
||
onChange={e => setF('hideEmpty', e.target.checked)}
|
||
/>
|
||
Απόκρυψη κενών
|
||
</label>
|
||
</div>
|
||
|
||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||
{!isLoading && visibleOrders.length === 0 && (
|
||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p>
|
||
)}
|
||
|
||
{visibleOrders.length > 0 && (
|
||
<div className="card overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-100">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Ανοίχτηκε</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Έκλεισε</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Πληρώθηκε</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατάσταση</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σημείωση</th>
|
||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
||
<th className="px-4 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{visibleOrders.map(o => {
|
||
const total = o.items
|
||
.filter(i => i.status !== 'cancelled')
|
||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||
|
||
// Opener / closer
|
||
const openerName = waiterMap[o.opened_by] || `#${o.opened_by}`
|
||
const closerName = o.closed_by ? (waiterMap[o.closed_by] || `#${o.closed_by}`) : null
|
||
|
||
// Latest payment info from items
|
||
const paidItems = o.items.filter(i => i.status === 'paid' && i.paid_by)
|
||
const payerIds = [...new Set(paidItems.map(i => i.paid_by))]
|
||
const latestPaidAt = paidItems.reduce((max, i) => (!max || i.paid_at > max) ? i.paid_at : max, null)
|
||
|
||
return (
|
||
<tr key={o.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 text-gray-500">{o.id}</td>
|
||
<td className="px-4 py-3 font-medium text-gray-800">{tableMap[o.table_id] || `#${o.table_id}`}</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
<div className="whitespace-nowrap">{fmtDt(o.opened_at)}</div>
|
||
<div className="text-xs text-gray-400">{openerName}</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
{o.closed_at ? <>
|
||
<div className="whitespace-nowrap">{fmtDt(o.closed_at)}</div>
|
||
{closerName && <div className="text-xs text-gray-400">{closerName}</div>}
|
||
</> : '—'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
{payerIds.length === 0 ? '—' : payerIds.length === 1 ? (
|
||
<>
|
||
<div className="whitespace-nowrap text-xs text-green-700 font-medium">{waiterMap[payerIds[0]] || `#${payerIds[0]}`}</div>
|
||
{latestPaidAt && <div className="text-xs text-gray-400">{fmtDt(latestPaidAt)}</div>}
|
||
</>
|
||
) : (
|
||
<button
|
||
onClick={() => setPayersModal(o)}
|
||
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 transition-colors"
|
||
>
|
||
{payerIds.length} σερβιτόροι
|
||
</button>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3"><StatusBadge status={o.status} /></td>
|
||
<td className="px-4 py-3 text-gray-500 text-xs max-w-[120px] truncate" title={o.notes || ''}>{o.notes || '—'}</td>
|
||
<td className="px-4 py-3 text-right font-semibold text-gray-800">€{total.toFixed(2)}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex gap-1 justify-end">
|
||
<button
|
||
onClick={() => setDetailOrder(o)}
|
||
className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7"
|
||
>
|
||
Λεπτομέρειες
|
||
</button>
|
||
<button
|
||
onClick={() => setPrintOrderId(o.id)}
|
||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7"
|
||
>
|
||
🖨
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">← Προηγ.</button>
|
||
<span className="text-sm text-gray-500">Σελίδα {page}</span>
|
||
<button onClick={() => setPage(p => p + 1)} disabled={orders.length < 50} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επόμ. →</button>
|
||
</div>
|
||
|
||
{detailOrder && (
|
||
<OrderDetailsModal
|
||
order={detailOrder}
|
||
tableMap={tableMap}
|
||
waiterMap={waiterMap}
|
||
printers={printers}
|
||
onClose={() => setDetailOrder(null)}
|
||
onPrint={(orderId, printerId) => printMutation.mutate({ orderId, printerId })}
|
||
/>
|
||
)}
|
||
|
||
{payersModal && (
|
||
<PayersModal
|
||
order={payersModal}
|
||
waiterMap={waiterMap}
|
||
onClose={() => setPayersModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{printOrderId !== null && printers.length > 0 && (
|
||
<PrintOrderModal
|
||
printers={printers}
|
||
onClose={() => setPrintOrderId(null)}
|
||
onPrint={(printerId) => { printMutation.mutate({ orderId: printOrderId, printerId }); setPrintOrderId(null) }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|