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:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

View 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>
)
}