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:
209
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
209
manager_dashboard/src/pages/ReportsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user