feat: added synopsis print on on print reports tab
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
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 { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||
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 SkeletonTable from '../shared/SkeletonTable'
|
||||
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 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() {
|
||||
const [mode, setMode] = useState('range')
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [businessDayId, setBusinessDayId] = 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: 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,
|
||||
})
|
||||
|
||||
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 logs = data?.logs || []
|
||||
const total = data?.total || 0
|
||||
const failed = data?.failed || 0
|
||||
|
||||
// Find most printed item
|
||||
// Derived stats for StatCards + Print modal
|
||||
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 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 (isError) return (
|
||||
@@ -58,7 +323,16 @@ export default function PrinterHistory() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<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} />
|
||||
{mode === 'workday' ? (
|
||||
@@ -73,10 +347,17 @@ export default function PrinterHistory() {
|
||||
</FilterBar>
|
||||
|
||||
<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(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>
|
||||
|
||||
<Panel title="Εργασίες Εκτύπωσης" padded={false}>
|
||||
@@ -115,6 +396,17 @@ export default function PrinterHistory() {
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{showPrintModal && (
|
||||
<PrintSummaryModal
|
||||
onClose={() => setShowPrintModal(false)}
|
||||
logs={logs}
|
||||
stats={stats}
|
||||
printers={printers}
|
||||
printerF={printerF}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user