Files
xenia-pos-local/manager_dashboard/src/pages/reports/staff/StaffLeaderboard.jsx
bonamin 8ba8c95ecd feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:04:38 +03:00

151 lines
7.7 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, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, avatarColor } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
const PODIUM_HEIGHTS = ['h-44', 'h-32', 'h-24']
const PODIUM_COLORS = [
'bg-gradient-to-b from-amber-200 to-amber-100 ring-amber-300',
'bg-gradient-to-b from-slate-200 to-slate-100 ring-slate-300',
'bg-gradient-to-b from-orange-200 to-orange-100 ring-orange-300',
]
const MEDAL_LABEL = ['1ος', '2ος', '3ος']
const PODIUM_ORDER = [1, 0, 2]
export default function StaffLeaderboard() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['shifts-leaderboard', from, to],
queryFn: () => client.get('/api/reports/shifts', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data),
staleTime: 60 * 1000,
})
const ranked = useMemo(() => {
const shifts = data?.shifts || []
const map = new Map()
shifts.forEach(s => {
const cur = map.get(s.waiter_id) || { waiter_id: s.waiter_id, waiter_name: s.waiter_name, shifts: 0, orders: 0, value: 0 }
cur.shifts += 1
cur.value += s.total_collected || 0
map.set(s.waiter_id, cur)
})
return [...map.values()]
.map(r => ({ ...r, avg_per_shift: r.shifts ? r.value / r.shifts : 0 }))
.sort((a, b) => b.value - a.value)
}, [data])
const top3 = ranked.slice(0, 3)
const chartData = ranked.map(r => ({
name: (r.waiter_name || '').split(' ')[0],
value: Math.round(r.value),
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<span className="ml-2 text-[12px] text-slate-500">{ranked.length} σερβιτόροι · άθροισμα περιόδου</span>
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Κορυφαίοι" subtitle={`${from}${to}`}>
{top3.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
) : (
<div className="flex items-end justify-center gap-6 px-12 pb-2 pt-6">
{PODIUM_ORDER.map(rank => {
const r = top3[rank]
if (!r) return <div key={rank} className="w-44" />
const initials = (r.waiter_name || '').split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
const cls = avatarColor(r.waiter_id)
return (
<div key={r.waiter_id} className="flex w-44 flex-col items-center">
<div className="mb-3 flex flex-col items-center">
<div className={`flex h-14 w-14 items-center justify-center rounded-full text-base font-semibold ring-2 ring-white ${cls}`}>
{initials}
</div>
<div className="mt-2 text-[13px] font-semibold text-slate-900">{r.waiter_name}</div>
<div className="font-mono text-[11px] uppercase tracking-wider text-slate-500">{r.shifts} βάρδιες</div>
</div>
<div className={`flex w-full ${PODIUM_HEIGHTS[rank]} flex-col items-center justify-end rounded-t-md ring-1 ring-inset ${PODIUM_COLORS[rank]} pb-3`}>
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-700">{MEDAL_LABEL[rank]}</div>
<div className="mt-1 font-mono text-[18px] font-medium tabular-nums text-slate-900">{fmtEUR(r.value)}</div>
</div>
</div>
)
})}
</div>
)}
</Panel>
<div className="mt-4 grid grid-cols-3 gap-4">
<div className="col-span-2">
<Panel title="Πλήρης Κατάταξη" padded={false}>
<DataTable>
<THead>
<TH align="center" className="w-12">Θέση</TH>
<TH>Σερβιτόρος</TH>
<TH align="right">Βάρδιες</TH>
<TH align="right">Συνολικά Έσοδα</TH>
<TH align="right">Μέσος / Βάρδια</TH>
</THead>
<tbody>
{ranked.map((r, i) => (
<TR key={r.waiter_id} striped>
<TD align="center" className="w-12">
<span className={`inline-flex h-6 w-6 items-center justify-center rounded-full font-mono text-[11px] font-semibold ${
i === 0 ? 'bg-amber-100 text-amber-800 ring-1 ring-inset ring-amber-300' :
i === 1 ? 'bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-300' :
i === 2 ? 'bg-orange-100 text-orange-800 ring-1 ring-inset ring-orange-300' :
'text-slate-400'
}`}>{i + 1}</span>
</TD>
<TD><WaiterAvatar name={r.waiter_name} id={r.waiter_id} /></TD>
<TD mono align="right">{r.shifts}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(r.value)}</TD>
<TD mono align="right">{fmtEUR(r.avg_per_shift)}</TD>
</TR>
))}
</tbody>
</DataTable>
</Panel>
</div>
<Panel title="Έσοδα ανά Σερβιτόρο" subtitle="Φθίνουσα σειρά">
<div style={{ height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 16, bottom: 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={64} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="value" fill="#60a5fa" radius={[0, 3, 3, 0]} barSize={14} />
</BarChart>
</ResponsiveContainer>
</div>
</Panel>
</div>
</div>
</div>
)
}