Files
simple-pos-system/manager_dashboard/src/pages/ReportsPage.jsx
bonamin 603fd45eaa Overhaul of the frontend on waiters, orders, and payment events
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>
2026-04-24 17:37:34 +03:00

937 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, 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>
)
}