Compare commits

...

1 Commits

Author SHA1 Message Date
a01a03623f feat: added synopsis print on on print reports tab 2026-05-28 10:59:14 +03:00

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Printer, AlertCircle, Trophy } from 'lucide-react' import { Printer, AlertCircle, Hash, Euro, FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client' import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar' import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge } from '../shared/TablePrimitives' import { Panel, DataTable, THead, TH, TR, TD, StatusBadge } from '../shared/TablePrimitives'
@@ -8,17 +9,263 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable' import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton' import ExportButton from '../shared/ExportButton'
import { fmtNum, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens' import { fmtNum, fmtEUR, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) } 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) } function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ─── Print Summary Modal ────────────────────────────────────────────────────
const PRINT_TYPES = [
{
id: 'quick',
label: 'Γρήγορη Σύνοψη',
desc: 'Συγκεντρωτικά στατιστικά — λίγο χαρτί',
},
{
id: 'orders',
label: 'Όλες οι Παραγγελίες',
desc: 'Κάθε παραγγελία με σύνολο, χωρίς ανάλυση ειδών',
},
{
id: 'detailed',
label: 'Πλήρης Ανάλυση',
desc: 'Κάθε παραγγελία + όλα τα είδη — καταναλώνει πολύ χαρτί',
},
]
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
const [printType, setPrintType] = useState('quick')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
async function handlePrint() {
if (targetPrinter !== 'browser') {
await handleThermalPrint()
return
}
handleBrowserPrint()
}
async function handleThermalPrint() {
// Map frontend print types to backend modes
// quick/orders → simple, detailed → extensive
const mode = printType === 'detailed' ? 'extensive' : 'simple'
const printerTargetId = printerF === 'all' ? 0 : parseInt(printerF, 10)
const fromDt = queryParams.from || (new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) + 'T00:00:00')
const toDt = queryParams.to || (new Date().toISOString().slice(0, 10) + 'T23:59:59')
setPrinting(true)
try {
await client.post('/api/reports/print/printer', {
printer_target_id: printerTargetId,
printer_id: parseInt(targetPrinter, 10),
mode,
from_dt: fromDt,
to_dt: toDt,
})
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=800,height=700')
if (!win) { onClose(); return }
const styles = `
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
h2 { font-size: 13px; font-weight: bold; margin: 14px 0 6px; }
.row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px dotted #ccc; }
.row:last-child { border-bottom: none; }
.section { margin-bottom: 16px; }
.order-block { border: 1px solid #ccc; border-radius: 4px; padding: 8px; margin-bottom: 8px; }
.order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 4px; }
.item-row { padding-left: 12px; display: flex; justify-content: space-between; font-size: 11px; color: #333; }
.failed { color: #c00; }
.top-items li { padding: 2px 0; }
@media print { body { padding: 0; } }
</style>
`
const filterLabel = printerF === 'all'
? 'Όλοι οι Εκτυπωτές'
: (printers.find(p => String(p.id) === printerF)?.name || printerF)
let body = ''
if (printType === 'quick') {
const topItems = Object.entries(stats.itemCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
const topWaiters = Object.entries(stats.waiterCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
const avgItems = stats.total > 0 ? (stats.totalItemQty / stats.total).toFixed(1) : '—'
body = `
<h1>Σύνοψη Εκτυπωτή</h1>
<div class="section">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
<div class="row"><span>Περίοδος</span><span>${stats.periodLabel}</span></div>
</div>
<h2>Στατιστικά</h2>
<div class="section">
<div class="row"><span>Συνολικές Εκτυπώσεις</span><span>${fmtNum(stats.total)}</span></div>
<div class="row"><span>Αποτυχημένες Εκτυπώσεις</span><span class="${stats.failed > 0 ? 'failed' : ''}">${fmtNum(stats.failed)}</span></div>
${stats.totalAmount != null ? `<div class="row"><span>Συνολικό Ποσό Παραγγελιών</span><span>${fmtEUR(stats.totalAmount)}</span></div>` : ''}
<div class="row"><span>Μέσος Αριθμός Ειδών ανά Παραγγελία</span><span>${avgItems}</span></div>
</div>
<h2>Top 3 Πιο Εκτυπωμένα Είδη</h2>
<div class="section">
<ol class="top-items" style="padding-left:16px">
${topItems.length ? topItems.map(([name, qty], i) => `<li>${i + 1}. ${name}${qty} τεμ.</li>`).join('') : '<li>—</li>'}
</ol>
</div>
${topWaiters.length ? `
<h2>Εκτυπώσεις ανά Σερβιτόρο</h2>
<div class="section">
${topWaiters.map(([name, cnt]) => `<div class="row"><span>${name}</span><span>${cnt}</span></div>`).join('')}
</div>` : ''}
`
} else if (printType === 'orders') {
body = `
<h1>Λίστα Παραγγελιών</h1>
<div class="section">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
</div>
<h2>Παραγγελίες</h2>
${logs.map(j => `
<div class="order-block ${!j.success ? 'failed' : ''}">
<div class="order-header">
<span>#${j.order_id} · ${j.table || '—'}</span>
<span>${fmtDateTime(j.printed_at)}</span>
</div>
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
${j.order_total != null ? `<div class="row"><span>Σύνολο</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
</div>
`).join('')}
`
} else {
body = `
<h1>Πλήρης Ανάλυση Εκτυπώσεων</h1>
<div class="section">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
</div>
${logs.map(j => `
<div class="order-block ${!j.success ? 'failed' : ''}">
<div class="order-header">
<span>#${j.order_id} · ${j.table || '—'}</span>
<span>${fmtDateTime(j.printed_at)}</span>
</div>
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
${(j.items || []).map(i => `<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`).join('')}
</div>
`).join('')}
`
}
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${body}</body></html>`)
win.document.close()
win.focus()
win.print()
onClose()
} // end handleBrowserPrint
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 PrinterHistory() { export default function PrinterHistory() {
const [mode, setMode] = useState('range') const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo()) const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today()) const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all') const [businessDayId, setBusinessDayId] = useState('all')
const [printerF, setPrinterF] = useState('all') const [printerF, setPrinterF] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').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 { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 }) const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
@@ -35,17 +282,35 @@ export default function PrinterHistory() {
staleTime: 60 * 1000, staleTime: 60 * 1000,
}) })
const printerOptions = [{ value: 'all', label: 'Όλοι οι Εκτυπωτές' }, ...((printersData?.printers || []).map(p => ({ value: String(p.id), label: p.name })))] const printers = printersData?.printers || []
const printerOptions = [{ value: 'all', label: 'Όλοι οι Εκτυπωτές' }, ...printers.map(p => ({ value: String(p.id), label: p.name }))]
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))] const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const logs = data?.logs || [] const logs = data?.logs || []
const total = data?.total || 0 const total = data?.total || 0
const failed = data?.failed || 0 const failed = data?.failed || 0
// Find most printed item // Derived stats for StatCards + Print modal
const itemCounts = {} const itemCounts = {}
logs.forEach(l => (l.items || []).forEach(i => { itemCounts[i.name] = (itemCounts[i.name] || 0) + i.quantity })) const waiterCounts = {}
let totalItemQty = 0
let totalAmount = null
logs.forEach(l => {
if (l.waiter) waiterCounts[l.waiter] = (waiterCounts[l.waiter] || 0) + 1
if (l.order_total != null) totalAmount = (totalAmount || 0) + l.order_total
;(l.items || []).forEach(i => {
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.quantity
totalItemQty += i.quantity
})
})
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0] 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}`
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div> if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
if (isError) return ( if (isError) return (
@@ -58,7 +323,16 @@ export default function PrinterHistory() {
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<FilterBar right={ <FilterBar right={
<ExportButton endpoint="/api/reports/printers/export" params={queryParams} filename={`printer-history-${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/printers/export" params={queryParams} filename={`printer-history-${from}-to-${to}.csv`} />
</div>
}> }>
<WorkDayDateToggle mode={mode} onChange={setMode} /> <WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? ( {mode === 'workday' ? (
@@ -73,10 +347,17 @@ export default function PrinterHistory() {
</FilterBar> </FilterBar>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-4 gap-4 mb-4">
<StatCard label="Συνολικές Εκτυπώσεις" value={fmtNum(total)} icon={Printer} /> <StatCard label="Συνολικές Εκτυπώσεις" value={fmtNum(total)} icon={Printer} />
<StatCard label="Αποτυχημένες" value={fmtNum(failed)} sub={failed > 0 ? 'ελέγξτε τον εκτυπωτή' : 'όλα καλά'} icon={AlertCircle} /> <StatCard label="Αποτυχημένες" value={fmtNum(failed)} sub={failed > 0 ? 'ελέγξτε τον εκτυπωτή' : 'όλα καλά'} icon={AlertCircle} />
<StatCard label=ιο Εκτυπωμένο" value={topItem ? topItem[0] : '—'} sub={topItem ? `${topItem[1]} τεμ.` : ''} icon={Trophy} accent /> <StatCard label=αραγγελίες Εκτυπώθηκαν" value={fmtNum(total - failed)} sub="επιτυχείς εκτυπώσεις" icon={Hash} />
<StatCard
label="Συνολικό Ποσό Παραγγελιών"
value={totalAmount != null ? fmtEUR(totalAmount) : '—'}
sub={totalAmount == null ? 'μη διαθέσιμο' : undefined}
icon={Euro}
accent={totalAmount != null}
/>
</div> </div>
<Panel title="Εργασίες Εκτύπωσης" padded={false}> <Panel title="Εργασίες Εκτύπωσης" padded={false}>
@@ -115,6 +396,17 @@ export default function PrinterHistory() {
)} )}
</Panel> </Panel>
</div> </div>
{showPrintModal && (
<PrintSummaryModal
onClose={() => setShowPrintModal(false)}
logs={logs}
stats={stats}
printers={printers}
printerF={printerF}
queryParams={queryParams}
/>
)}
</div> </div>
) )
} }