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>
This commit is contained in:
150
manager_dashboard/src/pages/reports/staff/StaffLeaderboard.jsx
Normal file
150
manager_dashboard/src/pages/reports/staff/StaffLeaderboard.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user