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:
@@ -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)} τεμ. · ${c.product_count} προϊόντα · ${pctRev}% εσόδων · ${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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user