Phase 3: scaffold Manager Dashboard — all pages, layout, routing

Includes: LoginPage (PIN pad), DashboardPage (30s polling table grid),
OrderDetailPage (full actions), ProductsPage (CRUD + printer zone),
WaitersPage (block/reset PIN/delete), TablesPage, ReportsPage
(shift summary + order history + CSV export), SettingsPage (printers
+ test print + sysadmin lock/unlock). TailwindCSS, React Query,
react-hot-toast. Docker Compose service on port 5174.
This commit is contained in:
2026-04-20 17:20:46 +03:00
parent 7f5bcfe4e1
commit 8f52156f5b
23 changed files with 1749 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import client from '../api/client'
import StatusBadge from '../components/StatusBadge'
function today() {
return new Date().toISOString().slice(0, 10)
}
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()
}
export default function ReportsPage() {
const [tab, setTab] = useState('shift')
const [shiftDate, setShiftDate] = useState(today())
const [historyFilters, setHistoryFilters] = useState({ from: today(), to: today(), status: '' })
return (
<div className="space-y-6 max-w-4xl">
<h1 className="text-xl font-bold text-gray-800">Αναφορές</h1>
<div className="flex gap-2">
<button onClick={() => setTab('shift')} className={`btn ${tab === 'shift' ? 'btn-primary' : 'btn-secondary'}`}>Σύνοψη βάρδιας</button>
<button onClick={() => setTab('history')} className={`btn ${tab === 'history' ? 'btn-primary' : 'btn-secondary'}`}>Ιστορικό παραγγελιών</button>
</div>
{tab === 'shift' && <ShiftTab date={shiftDate} setDate={setShiftDate} />}
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
</div>
)
}
function ShiftTab({ date, setDate }) {
const { data, isLoading } = useQuery({
queryKey: ['report-shift', date],
queryFn: () => client.get(`/api/reports/shift?date=${date}`).then(r => r.data),
})
const rows = data
? Object.entries(data.waiters).map(([name, s]) => ({
Σερβιτόρος: name,
Παραγγελίες: s.orders,
'Αντικείμενα': s.items,
'Σύνολο (€)': s.total.toFixed(2),
}))
: []
const grandTotal = rows.reduce((s, r) => s + parseFloat(r['Σύνολο (€)']), 0)
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div>
<label className="label">Ημερομηνία</label>
<input type="date" className="input w-44" value={date} onChange={e => setDate(e.target.value)} />
</div>
{rows.length > 0 && (
<button
onClick={() => csvDownload(rows, `shift_${date}.csv`)}
className="btn btn-secondary self-end"
>
Εξαγωγή 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-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
{Object.keys(rows[0]).map(h => (
<th key={h} className="text-left px-4 py-3 font-semibold text-gray-600">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{rows.map((r, i) => (
<tr key={i} className="hover:bg-gray-50">
{Object.values(r).map((v, j) => (
<td key={j} className="px-4 py-3 text-gray-800">{v}</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['Παραγγελίες'], 0)}</td>
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Αντικείμενα'], 0)}</td>
<td className="px-4 py-3 font-bold text-primary-700">{grandTotal.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
function HistoryTab({ filters, setFilters }) {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const params = new URLSearchParams({ from: filters.from, to: filters.to + 'T23:59:59', page })
if (filters.status) params.set('status', filters.status)
const { data: orders = [], isLoading } = useQuery({
queryKey: ['order-history', filters, page],
queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data),
})
function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) }
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="label">Από</label>
<input type="date" className="input w-40" value={filters.from} onChange={e => setF('from', e.target.value)} />
</div>
<div>
<label className="label">Έως</label>
<input type="date" className="input w-40" value={filters.to} onChange={e => setF('to', e.target.value)} />
</div>
<div>
<label className="label">Κατάσταση</label>
<select className="input 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>
{isLoading && <p className="text-gray-400">Φόρτωση</p>}
{!isLoading && orders.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p>
)}
{orders.length > 0 && (
<div className="card overflow-hidden">
<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-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{orders.map(o => {
const total = o.items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
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">{o.table_id}</td>
<td className="px-4 py-3 text-gray-600">
{new Date(o.opened_at).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="px-4 py-3"><StatusBadge status={o.status} /></td>
<td className="px-4 py-3 text-right font-semibold text-gray-800">{total.toFixed(2)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => navigate(`/orders/${o.id}`)}
className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7"
>
Προβολή
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Pagination */}
<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>
</div>
)
}