feat(frontend): workday summary modal, print analytics UI, staff zone-PIN management, report upgrades

manager_dashboard:
- DashboardPage: WorkdaySummaryModal integration (view / close-day flows); revenue chart upgrades
- WorkdaySummaryModal: new component — shows full shift/revenue summary before closing the day
- StaffTab: full rewrite — zone-assignment modal, reset-PIN modal, actions dropdown, richer staff card
- ProductsTab: digital-menu fields surfaced in product form
- PrintFontsTab: expanded font-size/weight controls per printer channel
- TablesConfigTab / TablesPage / tableColourStore: color picker and zone fields for tables
- FilterBar: unified filter component with date-range, printer, and category selectors
- CategoryPerformance / ProductPerformance / TableAnalytics: donut/bar charts,
  thermal+browser print modals, richer breakdown tables
- PrinterHistory: redesigned with per-printer drill-down and summary blocks
- TrafficAnalytics / WorkDaySummary / RevenueTrends / ShiftsOverview / StaffLeaderboard: polish pass
- tokens.js: design token updates

waiter_pwa:
- TableCard: show table color indicator from zone/colour config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:28:23 +03:00
parent 79bd3b0f41
commit ac7ec45279
21 changed files with 3353 additions and 645 deletions

View File

@@ -1,35 +1,323 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, CHART_PALETTE } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } 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) }
// ─── SVG donut chart (inline, for print window) ───────────────────────────────
function svgDonut(categories, size = 200) {
const total = categories.reduce((s, c) => s + c.revenue, 0)
if (!total) return ''
const cx = size / 2, cy = size / 2, R = size * 0.42, r = size * 0.22
let angle = -Math.PI / 2
const slices = categories.map((c, i) => {
const frac = c.revenue / total
const a0 = angle, a1 = angle + frac * 2 * Math.PI
angle = a1
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0)
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1)
const xi0 = cx + r * Math.cos(a0), yi0 = cy + r * Math.sin(a0)
const xi1 = cx + r * Math.cos(a1), yi1 = cy + r * Math.sin(a1)
const large = frac > 0.5 ? 1 : 0
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
const d = `M${xi0},${yi0} L${x0},${y0} A${R},${R} 0 ${large},1 ${x1},${y1} L${xi1},${yi1} A${r},${r} 0 ${large},0 ${xi0},${yi0} Z`
return `<path d="${d}" fill="${color}" />`
}).join('')
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">${slices}</svg>`
}
// ─── Print modal ─────────────────────────────────────────────────────────────
const PRINT_TYPES = [
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατηγορίες με τεμάχια, έσοδα και ποσοστό' },
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Κατηγορίες με γράφημα πίτας και αναλυτική εικόνα' },
]
function CategoryPrintModal({ onClose, categories, allProducts, soldProducts, printers, queryParams, periodLabel }) {
const [printType, setPrintType] = useState('smart')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function periodRow() {
if (queryParams.business_day_id)
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=820,height=800')
if (!win) { onClose(); return }
const totalRev = categories.reduce((s, c) => s + c.revenue, 0)
const totalQty = categories.reduce((s, c) => s + c.units_sold, 0)
let body = ''
if (printType === 'smart') {
const rows = categories.map(c => {
const pctRev = c.pct_rev ?? c.pct ?? 0
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : '0'
return `<tr>
<td>${c.category_name}</td>
<td class="num">${fmtNum(c.units_sold)}</td>
<td class="num">${fmtEUR(c.revenue)}</td>
<td class="num">${pctRev}%</td>
<td class="num">${pctQty}%</td>
</tr>`
}).join('')
body = `<table>
<thead><tr><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
</table>`
} else {
// Build sold-product lookup: product_id → {qty_sold, revenue, category_id}
const soldMap = {}
for (const p of soldProducts) soldMap[p.product_id] = p
// Group allProducts by category_id
const productsByCategory = {}
for (const ap of allProducts) {
if (!productsByCategory[ap.category_id]) productsByCategory[ap.category_id] = []
productsByCategory[ap.category_id].push(ap)
}
const donut = svgDonut(categories)
const legend = categories.map((c, i) => {
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
const pctRev = c.pct_rev ?? c.pct ?? 0
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
return `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span><span class="legend-name">${c.category_name}</span><span class="legend-val">${pctRev}% έσ. / ${pctQty}% τεμ.</span></div>`
}).join('')
// Full category blocks with per-product breakdown
const catBlocks = categories.map((c, i) => {
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
const pctRev = c.pct_rev ?? c.pct ?? 0
const pctQtyOfTotal = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
const catProducts = (productsByCategory[c.category_id] || [])
.map(ap => {
const sold = soldMap[ap.id]
return { name: ap.name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
})
.filter(p => p.qty > 0)
.sort((a, b) => b.qty - a.qty)
const catTotalQty = catProducts.reduce((s, p) => s + p.qty, 0)
const catTotalRev = catProducts.reduce((s, p) => s + p.revenue, 0)
const productRows = catProducts.map(p => {
const pctProdRev = catTotalRev ? (p.revenue / catTotalRev * 100).toFixed(1) : '0'
const pctProdQty = catTotalQty ? (p.qty / catTotalQty * 100).toFixed(1) : '0'
return `<tr>
<td class="prod-name">${p.name}</td>
<td class="num">${fmtNum(p.qty)}</td>
<td class="num">${fmtEUR(p.revenue)}</td>
<td class="num">${pctProdRev}%</td>
<td class="num">${pctProdQty}%</td>
</tr>`
}).join('')
return `<div class="cat-full-block" style="border-left: 4px solid ${color}">
<div class="cat-full-header">
<span class="cat-full-name">${c.category_name}</span>
<span class="cat-full-rev">${fmtEUR(c.revenue)}</span>
</div>
<div class="cat-full-sub">${fmtNum(c.units_sold)} τεμ. &nbsp;·&nbsp; ${c.product_count} προϊόντα &nbsp;·&nbsp; ${pctRev}% εσόδων &nbsp;·&nbsp; ${pctQtyOfTotal}% τεμ.</div>
${catProducts.length ? `
<table class="prod-table">
<colgroup><col/><col style="width:52px"/><col style="width:64px"/><col style="width:48px"/><col style="width:48px"/></colgroup>
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${productRows}</tbody>
</table>` : '<div class="no-sales">Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο</div>'}
</div>`
}).join('')
body = `
<div class="chart-row">
<div class="donut-wrap">${donut}</div>
<div class="legend">${legend}</div>
</div>
${catBlocks}`
}
const title = printType === 'smart' ? 'Σύνοψη Κατηγοριών' : 'Πλήρης Ανάλυση Κατηγοριών'
const styles = `<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 12px; font-family: 'Courier New', monospace; }
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
.num { text-align: right; font-weight: bold; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
.chart-row { display: flex; align-items: flex-start; gap: 24px; margin: 16px 0; }
.donut-wrap { flex-shrink: 0; }
.legend { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.legend-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.legend-dot { display: inline-block; width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.legend-name { flex: 1; }
.legend-val { font-weight: bold; min-width: 40px; text-align: right; }
.cards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.cat-card { padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 4px; }
.cat-name { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; color: #475569; margin-bottom: 4px; }
.cat-rev { font-size: 20px; font-weight: bold; font-family: 'Courier New', monospace; margin-bottom: 2px; }
.cat-sub { font-size: 10px; color: #64748b; }
.cat-full-block { padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 14px; }
.cat-full-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
.cat-full-name { font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; }
.cat-full-rev { font-size: 13px; font-weight: bold; font-family: 'Courier New', monospace; color: #334155; }
.cat-full-sub { font-size: 10px; color: #64748b; margin-bottom: 8px; }
.prod-table { width: 100%; border-collapse: collapse; font-size: 10px; margin-top: 6px; table-layout: fixed; }
.prod-table th, .prod-table td { padding: 2px 4px; border-bottom: 1px dotted #e2e8f0; font-size: 10px; }
.prod-table thead th { border-bottom: 1px solid #cbd5e1; font-size: 9px; text-transform: uppercase; color: #64748b; font-weight: bold; }
.prod-table th.num, .prod-table td.num { text-align: right; font-weight: bold; }
.prod-table th:first-child, .prod-table td:first-child { text-align: left; }
.prod-name { color: #1e293b; }
.no-sales { font-size: 10px; color: #94a3b8; font-style: italic; margin-top: 4px; }
@media print { body { padding: 0; } }
</style>`
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
<h1>${title}</h1>
<div class="meta">
${periodRow()}
<div class="row"><span>Κατηγορίες</span><span>${categories.length}</span></div>
</div>
${body}
</body></html>`)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from
body.to_dt = queryParams.to
}
setPrinting(true)
try {
await client.post('/api/reports/print/categories', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handlePrint() {
if (targetPrinter === 'browser') handleBrowserPrint()
else handleThermalPrint()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Κατηγοριών</h2>
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
<div className="space-y-2">
{PRINT_TYPES.map(pt => (
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
<div>
<div className="font-medium text-slate-800">{pt.label}</div>
<div className="text-[12px] text-slate-500">{pt.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
</button>
</div>
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function CategoryPerformance() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['category-performance', from, to],
queryKey: ['category-performance', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const { data: productsData } = useQuery({
queryKey: ['product-performance-for-cat', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const printers = printersData?.printers || []
const allProducts = allProductsData?.products || []
const soldProducts = productsData?.products || []
const categories = data?.categories || []
const pieData = categories.map((c, i) => ({
name: c.category_name,
value: c.revenue,
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
}))
const pieData = categories.map((c, i) => ({ name: c.category_name, value: c.revenue, color: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${fmtDateStr(from)}${fmtDateStr(to)}`
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} showChart /></div>
if (isError) return (
@@ -41,9 +329,20 @@ export default function CategoryPerformance() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<FilterBar right={
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
</button>
}>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
@@ -82,7 +381,6 @@ export default function CategoryPerformance() {
))}
</div>
</div>
<div className="mt-4">
<Panel title="Κατηγορίες" padded={false}>
<DataTable>
@@ -90,12 +388,7 @@ export default function CategoryPerformance() {
<tbody>
{categories.map((c, i) => (
<TR key={c.category_id} striped>
<TD>
<span className="inline-flex items-center gap-2 font-medium text-slate-900">
<span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
{c.category_name}
</span>
</TD>
<TD><span className="inline-flex items-center gap-2 font-medium text-slate-900"><span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />{c.category_name}</span></TD>
<TD mono align="right">{c.product_count}</TD>
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
@@ -109,6 +402,18 @@ export default function CategoryPerformance() {
</>
)}
</div>
{showPrintModal && (
<CategoryPrintModal
onClose={() => setShowPrintModal(false)}
categories={categories}
allProducts={allProducts}
soldProducts={soldProducts}
printers={printers}
queryParams={queryParams}
periodLabel={periodLabel}
/>
)}
</div>
)
}

View File

@@ -1,46 +1,299 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { AlertTriangle } from 'lucide-react'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, CHART_PALETTE } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } 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) }
// ─── SVG bar chart rendered as inline HTML ────────────────────────────────────
// Used inside the browser print window (no Recharts available there).
function svgBarChart(items, valueKey, labelKey, colorFn, width = 680, barH = 18, gap = 6) {
const top5 = [...items].slice(0, 5)
const maxVal = Math.max(1, ...top5.map(d => d[valueKey]))
const labelW = 160
const numW = 70
const barAreaW = width - labelW - numW - 16
const h = top5.length * (barH + gap) + gap
const bars = top5.map((d, i) => {
const y = gap + i * (barH + gap)
const bw = Math.round((d[valueKey] / maxVal) * barAreaW)
const color = colorFn(i)
return `
<text x="${labelW - 6}" y="${y + barH * 0.72}" text-anchor="end" font-size="11" fill="#475569" font-family="Arial,sans-serif">${d[labelKey]}</text>
<rect x="${labelW}" y="${y}" width="${bw}" height="${barH}" rx="3" fill="${color}" />
<text x="${labelW + bw + 4}" y="${y + barH * 0.72}" font-size="10" fill="#64748b" font-family="Arial,sans-serif">${d[valueKey]}</text>`
}).join('')
return `<svg width="${width}" height="${h}" xmlns="http://www.w3.org/2000/svg">${bars}</svg>`
}
// ─── Print modal ─────────────────────────────────────────────────────────────
const PRINT_TYPES = [
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Μόνο προϊόντα με πωλήσεις — σε σειρά ποσότητας' },
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Όλα τα προϊόντα, ακόμα και με 0 πωλήσεις, με αξία + γραφήματα' },
]
function ProductPrintModal({ onClose, products, allProducts, printers, queryParams, periodLabel }) {
const [printType, setPrintType] = useState('smart')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function periodRow() {
if (queryParams.business_day_id)
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=820,height=800')
if (!win) { onClose(); return }
const soldMap = {}
for (const p of products) soldMap[p.product_id] = p
const totalQty = products.reduce((s, p) => s + p.qty_sold, 0)
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
let tableSection = ''
if (printType === 'smart') {
const sorted = [...products].sort((a, b) => b.qty_sold - a.qty_sold)
const rows = sorted.map(p => {
const pctRev = totalRev ? (p.revenue / totalRev * 100).toFixed(1) : '0'
const pctQty = totalQty ? (p.qty_sold / totalQty * 100).toFixed(1) : '0'
return `<tr>
<td>${p.product_name}</td>
<td class="num">${fmtNum(p.qty_sold)}</td>
<td class="num">${fmtEUR(p.revenue)}</td>
<td class="num">${pctRev}%</td>
<td class="num">${pctQty}%</td>
</tr>`
}).join('')
tableSection = `<table>
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
</table>`
} else {
// Full: merge all products, including 0-sold
const merged = allProducts.map(ap => {
const sold = soldMap[ap.id]
return { name: ap.name, category: ap.category_name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
}).sort((a, b) => b.qty - a.qty)
// Top-5 bar charts (qty and revenue)
const top5qty = merged.filter(p => p.qty > 0).slice(0, 5)
const top5rev = merged.filter(p => p.revenue > 0).slice(0, 5).sort((a, b) => b.revenue - a.revenue)
const chartQty = svgBarChart(top5qty, 'qty', 'name', i => CHART_PALETTE[i % CHART_PALETTE.length])
const maxRev = Math.max(1, ...top5rev.map(d => d.revenue))
const labelW = 160, barH = 18, gap = 6, barAreaW = 680 - labelW - 90 - 16
const revBars = top5rev.map((d, i) => {
const y = gap + i * (barH + gap)
const bw = Math.round((d.revenue / maxRev) * barAreaW)
return `
<text x="${labelW - 6}" y="${y + barH * 0.72}" text-anchor="end" font-size="11" fill="#475569" font-family="Arial,sans-serif">${d.name}</text>
<rect x="${labelW}" y="${y}" width="${bw}" height="${barH}" rx="3" fill="${CHART_PALETTE[i % CHART_PALETTE.length]}" />
<text x="${labelW + bw + 4}" y="${y + barH * 0.72}" font-size="10" fill="#64748b" font-family="Arial,sans-serif">${fmtEUR(d.revenue)}</text>`
}).join('')
const revH = top5rev.length * (barH + gap) + gap
const chartRevSvg = `<svg width="680" height="${revH}" xmlns="http://www.w3.org/2000/svg">${revBars}</svg>`
const rows = merged.map(p => {
const pctRev = totalRev && p.revenue ? (p.revenue / totalRev * 100).toFixed(1) : '—'
const pctQty = totalQty && p.qty ? (p.qty / totalQty * 100).toFixed(1) : '—'
return `<tr class="${p.qty === 0 ? 'zero' : ''}">
<td>${p.name}</td>
<td class="cat">${p.category}</td>
<td class="num">${fmtNum(p.qty)}</td>
<td class="num">${fmtEUR(p.revenue)}</td>
<td class="num">${pctRev}%</td>
<td class="num">${pctQty}%</td>
</tr>`
}).join('')
tableSection = `
<div class="chart-section">
<div class="chart-title">Top 5 σε Τεμάχια</div>
${chartQty}
</div>
<div class="chart-section">
<div class="chart-title">Top 5 σε Έσοδα</div>
${chartRevSvg}
</div>
<table>
<thead><tr><th>Προϊόν</th><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td colspan="2">Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
</table>`
}
const title = printType === 'smart' ? 'Έξυπνη Σύνοψη Προϊόντων' : 'Πλήρης Ανάλυση Προϊόντων'
const styles = `<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
.chart-section { margin: 16px 0; }
.chart-title { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .06em; color: #64748b; margin-bottom: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 16px; font-family: 'Courier New', monospace; }
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
.num { text-align: right; font-weight: bold; }
.cat { color: #555; font-size: 10px; }
.zero { color: #aaa; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
@media print { body { padding: 0; } }
</style>`
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
<h1>${title}</h1>
<div class="meta">
${periodRow()}
<div class="row"><span>Προϊόντα με πωλήσεις</span><span>${products.length}</span></div>
${printType === 'full' ? `<div class="row"><span>Σύνολο ενεργών προϊόντων</span><span>${allProducts.length}</span></div>` : ''}
</div>
${tableSection}
</body></html>`)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from
body.to_dt = queryParams.to
}
setPrinting(true)
try {
await client.post('/api/reports/print/products', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handlePrint() {
if (targetPrinter === 'browser') handleBrowserPrint()
else handleThermalPrint()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Σύνοψης Προϊόντων</h2>
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
<div className="space-y-2">
{PRINT_TYPES.map(pt => (
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
<div>
<div className="font-medium text-slate-800">{pt.label}</div>
<div className="text-[12px] text-slate-500">{pt.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
{targetPrinter !== 'browser' && printType === 'full' && (
<p className="mt-1.5 text-[11px] text-amber-600">Η Πλήρης Ανάλυση σε θερμικό θα εκτυπώσει μόνο τη λίστα χωρίς γραφήματα.</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
</button>
</div>
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function ProductPerformance() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [catF, setCatF] = useState('all')
const [chartMode, setChartMode] = useState('revenue')
const [showPrintModal, setShowPrintModal] = useState(false)
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
from: from + 'T00:00:00',
to: to + 'T23:59:59',
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
...(catF !== 'all' ? { category_id: catF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['product-performance', from, to, catF],
queryKey: ['product-performance', mode, from, to, businessDayId, catF],
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const allProducts = allProductsData?.products || []
const printers = printersData?.printers || []
const products = data?.products || []
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
const top10 = products.slice(0, 10).map((p, i) => ({
name: p.product_name,
value: chartMode === 'revenue' ? p.revenue : p.qty_sold,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}))
const top10 = [...products]
.sort((a, b) => chartMode === 'revenue' ? b.revenue - a.revenue : b.qty_sold - a.qty_sold)
.slice(0, 10)
.map((p, i) => ({ name: p.product_name, value: chartMode === 'revenue' ? p.revenue : p.qty_sold, color: CHART_PALETTE[i % CHART_PALETTE.length] }))
const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${fmtDateStr(from)}${fmtDateStr(to)}`
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} showChart /></div>
if (isError) return (
@@ -53,26 +306,32 @@ export default function ProductPerformance() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
<div className="flex items-center gap-2">
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
</button>
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
</div>
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel
title="Top 10 Προϊόντα"
subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`}
right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
{label}
</button>
))}
</div>
}
>
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`} right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}</button>
))}
</div>
}>
{top10.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
@@ -83,9 +342,7 @@ export default function ProductPerformance() {
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => chartMode === 'revenue' ? '€' + v : String(v)} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>
{top10.map((d, i) => <Cell key={i} fill={d.color} />)}
</Bar>
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>{top10.map((d, i) => <Cell key={i} fill={d.color} />)}</Bar>
</BarChart>
</ResponsiveContainer>
</div>
@@ -98,10 +355,7 @@ export default function ProductPerformance() {
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<DataTable>
<THead>
<TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH>
<TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH>
</THead>
<THead><TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH><TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH></THead>
<tbody>
{products.map(p => {
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
@@ -121,6 +375,17 @@ export default function ProductPerformance() {
</Panel>
</div>
</div>
{showPrintModal && (
<ProductPrintModal
onClose={() => setShowPrintModal(false)}
products={products}
allProducts={allProducts}
printers={printers}
queryParams={queryParams}
periodLabel={periodLabel}
/>
)}
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, fmtDate } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtDateStr } 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) }
@@ -56,7 +56,7 @@ export default function RevenueTrends() {
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${from}${to}`}>
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${fmtDateStr(from)}${fmtDateStr(to)}`}>
{trends.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
) : (

View File

@@ -1,31 +1,222 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtMinutes } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtMinutes, fmtDate, fmtTime, fmtDateStr } 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) }
// ─── Print modal ─────────────────────────────────────────────────────────────
const PRINT_TYPES = [
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατάταξη τραπεζιών με παραγγελίες, μέση διάρκεια και έσοδα' },
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Ίδιο, με επιπλέον μέσο έσοδο ανά επίσκεψη' },
]
function TablePrintModal({ onClose, tables, printers, queryParams, periodLabel }) {
const [printType, setPrintType] = useState('smart')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=820,height=750')
if (!win) { onClose(); return }
let periodRow = ''
if (queryParams.business_day_id) {
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
} else {
const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
periodRow = `<div class="row"><span>Από</span><span>${fromStr}</span></div>
<div class="row"><span>Έως</span><span>${toStr}</span></div>`
}
const totalOrders = tables.reduce((s, t) => s + t.order_count, 0)
const totalRev = tables.reduce((s, t) => s + t.revenue, 0)
const rows = tables.map(t => {
const avgRev = t.order_count ? t.revenue / t.order_count : 0
const dur = t.avg_duration_minutes != null ? fmtMinutes(t.avg_duration_minutes) : '—'
if (printType === 'smart') {
return `<tr>
<td>${t.table_name}</td>
<td class="num">${fmtNum(t.order_count)}</td>
<td class="num">${dur}</td>
<td class="num">${fmtEUR(t.revenue)}</td>
</tr>`
}
return `<tr>
<td>${t.table_name}</td>
<td class="num">${fmtNum(t.order_count)}</td>
<td class="num">${dur}</td>
<td class="num">${fmtEUR(avgRev)}</td>
<td class="num">${fmtEUR(t.revenue)}</td>
</tr>`
}).join('')
const headers = printType === 'smart'
? `<th>Τραπέζι</th><th class="num">Παραγγελίες</th><th class="num">Μ. Διάρκεια</th><th class="num">Έσοδα</th>`
: `<th>Τραπέζι</th><th class="num">Παραγγελίες</th><th class="num">Μ. Διάρκεια</th><th class="num">Μ. / Επίσκεψη</th><th class="num">Έσοδα</th>`
const footCols = printType === 'smart'
? `<td>Σύνολο</td><td class="num">${fmtNum(totalOrders)}</td><td class="num">—</td><td class="num">${fmtEUR(totalRev)}</td>`
: `<td>Σύνολο</td><td class="num">${fmtNum(totalOrders)}</td><td class="num">—</td><td class="num">—</td><td class="num">${fmtEUR(totalRev)}</td>`
const title = printType === 'smart' ? 'Σύνοψη Τραπεζιών' : 'Πλήρης Ανάλυση Τραπεζιών'
const styles = `<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; max-width: 800px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
.meta { margin-bottom: 16px; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 8px; }
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
.num { text-align: right; font-weight: bold; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
@media print { body { padding: 0; } }
</style>`
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
<h1>${title}</h1>
<div class="meta">
${periodRow}
<div class="row"><span>Τραπέζια</span><span>${tables.length}</span></div>
</div>
<table>
<thead><tr>${headers}</tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr>${footCols}</tr></tfoot>
</table>
</body></html>`
win.document.write(html)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from
body.to_dt = queryParams.to
}
setPrinting(true)
try {
await client.post('/api/reports/print/tables', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handlePrint() {
if (targetPrinter === 'browser') handleBrowserPrint()
else handleThermalPrint()
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}
>
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Σύνοψης Τραπεζιών</h2>
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
<div className="space-y-2">
{PRINT_TYPES.map(pt => (
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
<div>
<div className="font-medium text-slate-800">{pt.label}</div>
<div className="text-[12px] text-slate-500">{pt.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">
Ακύρωση
</button>
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
</button>
</div>
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function TableAnalytics() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['table-analytics', from, to],
queryKey: ['table-analytics', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const printers = printersData?.printers || []
const tables = data?.tables || []
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${fmtDateStr(from)}${fmtDateStr(to)}`
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
@@ -37,10 +228,26 @@ export default function TableAnalytics() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
<div className="flex items-center gap-2">
<button
onClick={() => setShowPrintModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
<FileText className="h-3.5 w-3.5" />
Εκτύπωση Σύνοψης
</button>
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
</div>
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
@@ -94,6 +301,16 @@ export default function TableAnalytics() {
</>
)}
</div>
{showPrintModal && (
<TablePrintModal
onClose={() => setShowPrintModal(false)}
tables={tables}
printers={printers}
queryParams={queryParams}
periodLabel={periodLabel}
/>
)}
</div>
)
}

View File

@@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, ChartTooltip } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtNum } from '../shared/reportDesignTokens'
import { fmtNum, fmtDate, fmtTime } 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) }
@@ -17,17 +17,26 @@ const DOWS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυ
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
export default function TrafficAnalytics() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['traffic', from, to],
queryKey: ['traffic', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const byHour = useMemo(() => {
const hourMap = {}
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
@@ -36,12 +45,6 @@ export default function TrafficAnalytics() {
const byWeekday = data?.by_weekday || []
// Build heatmap matrix [dow][hourIndex] = orders
const matrix = useMemo(() => {
// Backend only gives aggregated by_hour, so build a simple row from that
return DOWS.map(() => HOURS.map(() => 0))
}, [data])
const chartData = byHour.map(h => ({
hour: `${String(h.hour).padStart(2, '0')}:00`,
orders: h.orders,
@@ -65,8 +68,15 @@ export default function TrafficAnalytics() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
@@ -96,7 +106,7 @@ export default function TrafficAnalytics() {
<div className="mt-4">
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
<div className="grid grid-cols-7 gap-3">
{byWeekday.map((d, i) => {
{byWeekday.map((d) => {
const intensity = d.orders / maxByDow
return (
<div key={d.day} className="flex flex-col items-center gap-2">

View File

@@ -13,7 +13,7 @@ import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime, fmtDateStr } 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) }
@@ -228,7 +228,7 @@ export default function WorkDaySummary() {
<div className="flex-1 overflow-y-auto p-6">
{chartData.length > 0 && (
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${from}${to}`}>
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${fmtDateStr(from)}${fmtDateStr(to)}`}>
<div style={{ height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>