@@ -308,7 +426,7 @@ export default function PrinterHistory() {
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
- : `${from} → ${to}`
+ : `${fmtDateStr(from)} → ${fmtDateStr(to)}`
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }
diff --git a/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx b/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx
index 58f8ea0..f2ab53e 100644
--- a/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx
@@ -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 `
`
+ }).join('')
+ return `
`
+}
+
+// ─── 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 `
Εργάσιμη Μέρα${periodLabel}
`
+ const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
+ const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
+ return `
Από${f}
Έως${t}
`
+ }
+
+ 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 `
+ | ${c.category_name} |
+ ${fmtNum(c.units_sold)} |
+ ${fmtEUR(c.revenue)} |
+ ${pctRev}% |
+ ${pctQty}% |
+
`
+ }).join('')
+ body = `
+ | Κατηγορία | Τεμ. | Έσοδα | % Έσ. | % Τεμ. |
+ ${rows}
+ | Σύνολο | ${fmtNum(totalQty)} | ${fmtEUR(totalRev)} | 100% | 100% |
+
`
+ } 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 `
${c.category_name}${pctRev}% έσ. / ${pctQty}% τεμ.
`
+ }).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 `
+ | ${p.name} |
+ ${fmtNum(p.qty)} |
+ ${fmtEUR(p.revenue)} |
+ ${pctProdRev}% |
+ ${pctProdQty}% |
+
`
+ }).join('')
+
+ return `
+
+
${fmtNum(c.units_sold)} τεμ. · ${c.product_count} προϊόντα · ${pctRev}% εσόδων · ${pctQtyOfTotal}% τεμ.
+ ${catProducts.length ? `
+
+
+ | Προϊόν | Τεμ. | Έσοδα | % Έσ. | % Τεμ. |
+ ${productRows}
+
` : '
Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο
'}
+
`
+ }).join('')
+
+ body = `
+
+ ${catBlocks}`
+ }
+
+ const title = printType === 'smart' ? 'Σύνοψη Κατηγοριών' : 'Πλήρης Ανάλυση Κατηγοριών'
+ const styles = ``
+
+ win.document.write(`
${title}${styles}
+
${title}
+
+ ${body}
+ `)
+ 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 (
+
{ if (e.target === e.currentTarget) onClose() }}>
+
+
+
Εκτύπωση Κατηγοριών
+
+
+
+
+
Τύπος Εκτύπωσης
+
+ {PRINT_TYPES.map(pt => (
+
+ ))}
+
+
+
+
Εκτυπωτής Προορισμού
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ─── 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
if (isError) return (
@@ -41,9 +329,20 @@ export default function CategoryPerformance() {
return (
-
-
-
+ 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">
+ Εκτύπωση Σύνοψης
+
+ }>
+
+ {mode === 'workday' ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
@@ -82,7 +381,6 @@ export default function CategoryPerformance() {
))}
-
@@ -90,12 +388,7 @@ export default function CategoryPerformance() {
{categories.map((c, i) => (
- |
-
-
- {c.category_name}
-
- |
+ {c.category_name} |
{c.product_count} |
{fmtNum(c.units_sold)} |
{fmtEUR(c.revenue)} |
@@ -109,6 +402,18 @@ export default function CategoryPerformance() {
>
)}
+
+ {showPrintModal && (
+ setShowPrintModal(false)}
+ categories={categories}
+ allProducts={allProducts}
+ soldProducts={soldProducts}
+ printers={printers}
+ queryParams={queryParams}
+ periodLabel={periodLabel}
+ />
+ )}
)
}
diff --git a/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx b/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx
index b40daac..d2e1e63 100644
--- a/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx
@@ -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 `
+ ${d[labelKey]}
+
+ ${d[valueKey]}`
+ }).join('')
+
+ return ``
+}
+
+// ─── 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 `Εργάσιμη Μέρα${periodLabel}
`
+ const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
+ const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
+ return `Από${f}
Έως${t}
`
+ }
+
+ 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 `
+ | ${p.product_name} |
+ ${fmtNum(p.qty_sold)} |
+ ${fmtEUR(p.revenue)} |
+ ${pctRev}% |
+ ${pctQty}% |
+
`
+ }).join('')
+ tableSection = `
+ | Προϊόν | Τεμ. | Έσοδα | % Έσ. | % Τεμ. |
+ ${rows}
+ | Σύνολο | ${fmtNum(totalQty)} | ${fmtEUR(totalRev)} | 100% | 100% |
+
`
+ } 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 `
+ ${d.name}
+
+ ${fmtEUR(d.revenue)}`
+ }).join('')
+ const revH = top5rev.length * (barH + gap) + gap
+ const chartRevSvg = ``
+
+ 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 `
+ | ${p.name} |
+ ${p.category} |
+ ${fmtNum(p.qty)} |
+ ${fmtEUR(p.revenue)} |
+ ${pctRev}% |
+ ${pctQty}% |
+
`
+ }).join('')
+
+ tableSection = `
+
+
Top 5 σε Τεμάχια
+ ${chartQty}
+
+
+
Top 5 σε Έσοδα
+ ${chartRevSvg}
+
+
+ | Προϊόν | Κατηγορία | Τεμ. | Έσοδα | % Έσ. | % Τεμ. |
+ ${rows}
+ | Σύνολο | ${fmtNum(totalQty)} | ${fmtEUR(totalRev)} | 100% | 100% |
+
`
+ }
+
+ const title = printType === 'smart' ? 'Έξυπνη Σύνοψη Προϊόντων' : 'Πλήρης Ανάλυση Προϊόντων'
+ const styles = ``
+
+ win.document.write(`${title}${styles}
+ ${title}
+
+ ${tableSection}
+ `)
+ 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 (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
Εκτύπωση Σύνοψης Προϊόντων
+
+
+
+
+
Τύπος Εκτύπωσης
+
+ {PRINT_TYPES.map(pt => (
+
+ ))}
+
+
+
+
Εκτυπωτής Προορισμού
+
+
+
+
+ {targetPrinter !== 'browser' && printType === 'full' && (
+
Η Πλήρης Ανάλυση σε θερμικό θα εκτυπώσει μόνο τη λίστα χωρίς γραφήματα.
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+// ─── 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
if (isError) return (
@@ -53,26 +306,32 @@ export default function ProductPerformance() {
return (
+
+
+
+
}>
-
-
+
+ {mode === 'workday' ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
-
- {[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
-
- ))}
-
- }
- >
+
+ {[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
+
+ ))}
+
+ }>
{top10.length === 0 ? (
) : (
@@ -83,9 +342,7 @@ export default function ProductPerformance() {
chartMode === 'revenue' ? '€' + v : String(v)} />
chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
-
- {top10.map((d, i) => | )}
-
+ {top10.map((d, i) => | )}
@@ -98,10 +355,7 @@ export default function ProductPerformance() {
) : (
-
- Προϊόν | Τεμάχια | Έσοδα |
- % Συνόλου | Παραγγελίες |
-
+ Προϊόν | Τεμάχια | Έσοδα | % Συνόλου | Παραγγελίες |
{products.map(p => {
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
@@ -121,6 +375,17 @@ export default function ProductPerformance() {
+
+ {showPrintModal && (
+ setShowPrintModal(false)}
+ products={products}
+ allProducts={allProducts}
+ printers={printers}
+ queryParams={queryParams}
+ periodLabel={periodLabel}
+ />
+ )}
)
}
diff --git a/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx b/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
index fc66641..2afc50d 100644
--- a/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
@@ -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() {
-
g === gran)?.[1] || gran} · ${from} → ${to}`}>
+ g === gran)?.[1] || gran} · ${fmtDateStr(from)} → ${fmtDateStr(to)}`}>
{trends.length === 0 ? (
) : (
diff --git a/manager_dashboard/src/pages/reports/restaurant/TableAnalytics.jsx b/manager_dashboard/src/pages/reports/restaurant/TableAnalytics.jsx
index 2ccbfe4..d92a64e 100644
--- a/manager_dashboard/src/pages/reports/restaurant/TableAnalytics.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/TableAnalytics.jsx
@@ -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 = `Εργάσιμη Μέρα${periodLabel}
`
+ } else {
+ const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
+ const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
+ periodRow = `Από${fromStr}
+ Έως${toStr}
`
+ }
+
+ 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 `
+ | ${t.table_name} |
+ ${fmtNum(t.order_count)} |
+ ${dur} |
+ ${fmtEUR(t.revenue)} |
+
`
+ }
+ return `
+ | ${t.table_name} |
+ ${fmtNum(t.order_count)} |
+ ${dur} |
+ ${fmtEUR(avgRev)} |
+ ${fmtEUR(t.revenue)} |
+
`
+ }).join('')
+
+ const headers = printType === 'smart'
+ ? `Τραπέζι | Παραγγελίες | Μ. Διάρκεια | Έσοδα | `
+ : `Τραπέζι | Παραγγελίες | Μ. Διάρκεια | Μ. / Επίσκεψη | Έσοδα | `
+ const footCols = printType === 'smart'
+ ? `Σύνολο | ${fmtNum(totalOrders)} | — | ${fmtEUR(totalRev)} | `
+ : `Σύνολο | ${fmtNum(totalOrders)} | — | — | ${fmtEUR(totalRev)} | `
+
+ const title = printType === 'smart' ? 'Σύνοψη Τραπεζιών' : 'Πλήρης Ανάλυση Τραπεζιών'
+ const styles = ``
+
+ const html = `${title}${styles}
+ ${title}
+
+
+ ${headers}
+ ${rows}
+ ${footCols}
+
+ `
+
+ 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 (
+ { if (e.target === e.currentTarget) onClose() }}
+ >
+
+
+
Εκτύπωση Σύνοψης Τραπεζιών
+
+
+
+
+
Τύπος Εκτύπωσης
+
+ {PRINT_TYPES.map(pt => (
+
+ ))}
+
+
+
+
Εκτυπωτής Προορισμού
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ─── 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
if (isError) return (
@@ -37,10 +228,26 @@ export default function TableAnalytics() {
return (
+
+
+
+
}>
-
-
+
+ {mode === 'workday' ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
@@ -94,6 +301,16 @@ export default function TableAnalytics() {
>
)}
+
+ {showPrintModal && (
+
setShowPrintModal(false)}
+ tables={tables}
+ printers={printers}
+ queryParams={queryParams}
+ periodLabel={periodLabel}
+ />
+ )}
)
}
diff --git a/manager_dashboard/src/pages/reports/restaurant/TrafficAnalytics.jsx b/manager_dashboard/src/pages/reports/restaurant/TrafficAnalytics.jsx
index c3ebb2f..f2d07f6 100644
--- a/manager_dashboard/src/pages/reports/restaurant/TrafficAnalytics.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/TrafficAnalytics.jsx
@@ -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 (
-
-
+
+ {mode === 'workday' ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
@@ -96,7 +106,7 @@ export default function TrafficAnalytics() {
- {byWeekday.map((d, i) => {
+ {byWeekday.map((d) => {
const intensity = d.orders / maxByDow
return (
diff --git a/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx b/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
index eeb08bd..beccbbf 100644
--- a/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
@@ -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() {
{chartData.length > 0 && (
-
+
diff --git a/manager_dashboard/src/pages/reports/shared/FilterBar.jsx b/manager_dashboard/src/pages/reports/shared/FilterBar.jsx
index c70bcac..04f5c8f 100644
--- a/manager_dashboard/src/pages/reports/shared/FilterBar.jsx
+++ b/manager_dashboard/src/pages/reports/shared/FilterBar.jsx
@@ -155,24 +155,168 @@ export function FilterSelect({ value, onChange, options, label, icon: Icon }) {
// ── Date input ─────────────────────────────────────────────────────────────────
-export function FilterDateInput({ value, onChange, label }) {
+const GR_MONTHS = ['Ιανουάριος','Φεβρουάριος','Μάρτιος','Απρίλιος','Μάιος','Ιούνιος','Ιούλιος','Αύγουστος','Σεπτέμβριος','Οκτώβριος','Νοέμβριος','Δεκέμβριος']
+const GR_DAYS = ['Κυ','Δε','Τρ','Τε','Πε','Πα','Σα']
+
+function fmtGR(value) {
+ if (!value) return ''
+ const [y, m, d] = value.split('-')
+ if (!y || !m || !d) return value
+ return `${d}/${m}/${y}`
+}
+
+function CalendarDropdown({ value, onCommit, onClose }) {
+ const today = new Date()
+ const initYear = value ? parseInt(value.slice(0, 4)) : today.getFullYear()
+ const initMonth = value ? parseInt(value.slice(5, 7)) - 1 : today.getMonth()
+
+ const [viewYear, setViewYear] = useState(initYear)
+ const [viewMonth, setViewMonth] = useState(initMonth)
+
+ const selectedY = value ? parseInt(value.slice(0, 4)) : null
+ const selectedM = value ? parseInt(value.slice(5, 7)) - 1 : null
+ const selectedD = value ? parseInt(value.slice(8, 10)) : null
+
+ function prevMonth(e) {
+ e.stopPropagation()
+ if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
+ else setViewMonth(m => m - 1)
+ }
+ function nextMonth(e) {
+ e.stopPropagation()
+ if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
+ else setViewMonth(m => m + 1)
+ }
+
+ // Build grid: first cell = weekday of 1st of month (0=Sun)
+ const firstDow = new Date(viewYear, viewMonth, 1).getDay()
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
+ const cells = []
+ for (let i = 0; i < firstDow; i++) cells.push(null)
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d)
+
+ function selectDay(e, day) {
+ e.stopPropagation()
+ const mm = String(viewMonth + 1).padStart(2, '0')
+ const dd = String(day).padStart(2, '0')
+ onCommit(`${viewYear}-${mm}-${dd}`)
+ onClose()
+ }
+
+ const todayY = today.getFullYear()
+ const todayM = today.getMonth()
+ const todayD = today.getDate()
+
return (
-