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

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

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

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

View File

@@ -0,0 +1,659 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function fmtDateTime(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function roleBadge(role) {
if (role === 'manager' || role === 'sysadmin') {
return { label: 'Διευθυντής', bg: '#ede9fe', color: '#6d28d9' }
}
return { label: 'Σερβιτόρος', bg: '#e0f2fe', color: '#0369a1' }
}
// All payment methods with their display config
const PAY_METHODS = [
{ key: 'cash', label: 'Μετρητά', color: '#16a34a', bg: '#dcfce7' },
{ key: 'card', label: 'Κάρτα', color: '#2563eb', bg: '#dbeafe' },
{ key: 'transfer', label: 'Τρ. Μεταφ.', color: '#7c3aed', bg: '#ede9fe' },
{ key: 'treat', label: 'Κεράστηκε', color: '#b45309', bg: '#fef3c7' },
{ key: 'other', label: 'Άλλο', color: '#6b7280', bg: '#f3f4f6' },
]
// ── Sub-components ────────────────────────────────────────────────────────────
function TabBtn({ label, active, onClick, badge, warn }) {
return (
<button
onClick={onClick}
style={{
padding: '9px 18px',
border: 'none',
background: 'none',
fontSize: 13,
fontWeight: active ? 700 : 500,
color: active ? '#111315' : '#6b7280',
cursor: 'pointer',
borderBottom: active ? '2px solid #111315' : '2px solid transparent',
transition: 'all 150ms',
display: 'flex',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
}}
>
{label}
{badge != null && (
<span style={{
minWidth: 18, height: 18, borderRadius: 99,
background: warn ? '#fee2e2' : '#f3f4f6',
color: warn ? '#dc2626' : '#374151',
fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 5px',
}}>{badge}</span>
)}
</button>
)
}
function StatPill({ label, value, accent = '#374151' }) {
return (
<div style={{
background: '#f9fafb', border: '1px solid #f0f0ef',
borderRadius: 10, padding: '12px 16px',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</span>
<span style={{ fontSize: 22, fontWeight: 800, color: accent, letterSpacing: '-0.02em' }}>{value}</span>
</div>
)
}
function SectionTitle({ children }) {
return (
<div style={{
fontSize: 11, fontWeight: 700, color: '#9ca3af',
textTransform: 'uppercase', letterSpacing: '0.06em',
marginBottom: 10,
}}>{children}</div>
)
}
function PaymentBar({ w, total }) {
if (!total) return null
const bars = PAY_METHODS.filter(m => (w[m.key] || 0) > 0)
if (bars.length === 0) return null
return (
<div style={{ marginTop: 6 }}>
<div style={{ display: 'flex', height: 7, borderRadius: 99, overflow: 'hidden', gap: 1 }}>
{bars.map(m => (
<div key={m.key} style={{ flex: w[m.key], background: m.color }}
title={`${m.label}: ${Math.round((w[m.key] / total) * 100)}%`} />
))}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 5, flexWrap: 'wrap' }}>
{bars.map(m => (
<span key={m.key} style={{ fontSize: 10, color: m.color, fontWeight: 700 }}>
{m.label} {fmt(w[m.key])}
</span>
))}
</div>
</div>
)
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function OverviewTab({ data }) {
const waiters = data.waiters.filter(w => w.role === 'waiter')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
<StatPill label="Συνολικά Έσοδα" value={fmt(data.total_revenue)} accent="#111315" />
<StatPill label="Παραγγελίες" value={String(data.total_orders)} accent="#3758c9" />
<StatPill label="Αντικείμενα" value={String(data.total_items_ordered)} accent="#7a44c9" />
<StatPill label="Σερβιτόροι" value={String(waiters.length)} accent="#0d7a8a" />
</div>
<div>
<SectionTitle>Ώρα Λειτουργίας</SectionTitle>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '12px 16px', flex: 1, minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, marginBottom: 2 }}>ΑΝΟΙΓΜΑ</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{fmtDateTime(data.opened_at)}</div>
</div>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '12px 16px', flex: 1, minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, marginBottom: 2 }}>ΚΛΕΙΣΙΜΟ</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>
{data.closed_at
? fmtDateTime(data.closed_at)
: <span style={{ color: '#16a34a' }}>Ημέρα Ενεργή</span>}
</div>
</div>
</div>
</div>
<div>
<SectionTitle>Κατανομή Πληρωμών</SectionTitle>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '16px 18px' }}>
{(() => {
const totals = { cash: 0, card: 0, transfer: 0, treat: 0, other: 0 }
for (const w of data.waiters) {
for (const k of Object.keys(totals)) totals[k] += (w[k] || 0)
}
return <PaymentBar w={totals} total={data.total_revenue} />
})()}
</div>
</div>
</div>
)
}
function WaitersTab({ data }) {
const [expanded, setExpanded] = useState(null)
// Compute the widest rendered string for each of the 4 metric columns so we
// can give every row the same fixed width — no DOM measurement needed, just
// pick the longest formatted value across all waiters and use ch units.
const colWidths = data.waiters.reduce(
(acc, w) => ({
orders: Math.max(acc.orders, String(w.orders_opened).length),
items: Math.max(acc.items, String(w.items_ordered).length),
value: Math.max(acc.value, fmt(w.total_items_value).length),
collected: Math.max(acc.collected, fmt(w.total_collected).length),
}),
{ orders: 4, items: 4, value: 8, collected: 8 }
)
// Convert char-count to px using ~8.5px per char at font-size 16, plus label + padding
const colPx = {
orders: Math.max(colWidths.orders * 9 + 16, 72),
items: Math.max(colWidths.items * 9 + 16, 72),
value: Math.max(colWidths.value * 8 + 16, 96),
collected: Math.max(colWidths.collected * 8 + 16, 96),
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.waiters.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν σερβιτόροι</div>
)}
{data.waiters.map(w => {
const badge = roleBadge(w.role)
const isOpen = expanded === w.waiter_id
return (
<div key={w.waiter_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
}}>
<button
onClick={() => setExpanded(isOpen ? null : w.waiter_id)}
style={{
width: '100%', background: 'white', border: 'none', cursor: 'pointer',
padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left',
}}
>
{/* Avatar */}
<div style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: badge.bg, color: badge.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, fontWeight: 800,
}}>
{w.waiter_name.charAt(0).toUpperCase()}
</div>
{/* Name + role */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>{w.waiter_name}</div>
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 99,
background: badge.bg, color: badge.color,
}}>{badge.label}</span>
</div>
{/* 4 fixed-width metric columns — same width for every row */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{[
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
{ label: 'ΑΝΤΙΚΕΙΜ.', value: w.items_ordered, width: colPx.items, color: '#111315' },
{ label: 'ΑΞΙΑ ΠΑΡ.', value: fmt(w.total_items_value),width: colPx.value, color: '#374151' },
{ label: 'ΕΙΣΠΡΑΞΗ', value: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
].map(col => (
<div key={col.label} style={{
width: col.width, flexShrink: 0, textAlign: 'right',
padding: '0 6px',
}}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap' }}>{col.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: col.color, whiteSpace: 'nowrap' }}>{col.value}</div>
</div>
))}
</div>
{/* Chevron */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 200ms' }}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{isOpen && (
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 4 cells, full-width grid, equal columns */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{ label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.', value: w.items_paid, color: '#111315' },
{ label: 'ΕΝΑΡΞΗ ΒΑΡΔΙΑΣ', value: fmtTime(w.shift_started_at), color: '#111315' },
{
label: 'ΛΗΞΗ ΒΑΡΔΙΑΣ',
value: w.shift_ended_at ? fmtTime(w.shift_ended_at) : 'Βάρδια Ενεργή',
color: w.shift_ended_at ? '#111315' : '#16a34a',
},
{
label: 'ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ',
value: w.starting_cash != null ? fmt(w.starting_cash) : '—',
color: '#111315',
},
].map(cell => (
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: cell.color, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.value}</div>
</div>
))}
</div>
<PaymentBar w={w} total={w.total_collected} />
</div>
)}
</div>
)
})}
</div>
)
}
function ZonesTab({ data }) {
const maxRev = Math.max(...data.zones.map(z => z.revenue), 1)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.zones.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν ζώνες</div>
)}
{data.zones.map(z => (
<div key={z.group_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12,
padding: '16px 18px', background: 'white',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{
width: 12, height: 12, borderRadius: 3, flexShrink: 0,
background: z.color || '#9ca3af',
}} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#111315', flex: 1 }}>{z.group_name}</span>
<span style={{ fontSize: 20, fontWeight: 800, color: '#111315' }}>{fmt(z.revenue)}</span>
</div>
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, marginBottom: 12, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99,
background: z.color || '#9ca3af',
width: `${Math.round((z.revenue / maxRev) * 100)}%`,
transition: 'width 400ms ease',
}} />
</div>
<div style={{ display: 'flex', gap: 20 }}>
<div>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΠΑΡΑΓΓΕΛΙΕΣ </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{z.orders}</span>
</div>
<div>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΑΝΤΙΚΕΙΜΕΝΑ </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{z.items}</span>
</div>
{z.orders > 0 && (
<div>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>Μ.Ο./ΠΑΡΑΓ. </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{fmt(z.revenue / z.orders)}</span>
</div>
)}
</div>
</div>
))}
</div>
)
}
function PrintersTab({ data }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.printers.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν δεδομένα εκτύπωσης</div>
)}
{data.printers.map(p => {
const successRate = p.jobs > 0 ? Math.round((p.success / p.jobs) * 100) : 0
return (
<div key={p.printer_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12,
padding: '16px 18px', background: 'white',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>{p.printer_name}</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>{p.ip_address}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 18, fontWeight: 800, color: '#111315' }}>{fmt(p.items_value)}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>αξία εκτυπ.</div>
</div>
<span style={{
fontSize: 12, fontWeight: 700, padding: '3px 10px', borderRadius: 99, marginLeft: 4,
background: successRate === 100 ? '#dcfce7' : successRate >= 80 ? '#fef9c3' : '#fee2e2',
color: successRate === 100 ? '#16a34a' : successRate >= 80 ? '#a16207' : '#dc2626',
}}>{successRate}%</span>
</div>
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, marginBottom: 12, overflow: 'hidden' }}>
<div style={{ height: '100%', display: 'flex' }}>
<div style={{ flex: p.success, background: '#16a34a' }} />
<div style={{ flex: p.failed, background: '#dc2626' }} />
</div>
</div>
<div style={{ display: 'flex', gap: 20 }}>
<div><span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΕΡΓΑΣΙΕΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.jobs}</span></div>
<div><span style={{ fontSize: 11, color: '#16a34a', fontWeight: 600 }}>ΕΠΙΤΥΧΕΙΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.success}</span></div>
{p.failed > 0 && <div><span style={{ fontSize: 11, color: '#dc2626', fontWeight: 600 }}>ΑΠΟΤΥΧΕΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.failed}</span></div>}
</div>
</div>
)
})}
</div>
)
}
function ChecksTab({ data, onForceClose, closing, isOpen }) {
const { checks } = data
const allGood = checks.open_orders === 0
const hasUnpaid = checks.with_unpaid_items > 0
const hasActiveShifts = checks.active_shifts > 0
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
border: `1px solid ${checks.open_orders > 0 ? '#fecaca' : '#bbf7d0'}`,
borderRadius: 12, padding: '16px 18px', background: checks.open_orders > 0 ? '#fff5f5' : '#f0fdf4',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<div style={{
width: 34, height: 34, borderRadius: 99, flexShrink: 0, marginTop: 1,
background: checks.open_orders > 0 ? '#fee2e2' : '#dcfce7',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
}}>
{checks.open_orders > 0 ? '⚠️' : '✓'}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Ανοιχτά Τραπέζια</div>
{checks.open_orders > 0 ? (
<div style={{ fontSize: 13, color: '#dc2626', marginTop: 2 }}>
{checks.open_orders} τραπέζια ακόμα ανοιχτά
{hasUnpaid && ` · ${checks.with_unpaid_items} με απλήρωτα αντικείμενα`}
</div>
) : (
<div style={{ fontSize: 13, color: '#16a34a', marginTop: 2 }}>Όλα τα τραπέζια έχουν κλείσει</div>
)}
</div>
</div>
<div style={{
border: `1px solid ${hasActiveShifts ? '#fde68a' : '#bbf7d0'}`,
borderRadius: 12, padding: '16px 18px', background: hasActiveShifts ? '#fffbeb' : '#f0fdf4',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<div style={{
width: 34, height: 34, borderRadius: 99, flexShrink: 0, marginTop: 1,
background: hasActiveShifts ? '#fef3c7' : '#dcfce7',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
}}>
{hasActiveShifts ? '⏳' : '✓'}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Ενεργές Βάρδιες</div>
{hasActiveShifts ? (
<div style={{ fontSize: 13, color: '#92400e', marginTop: 2 }}>
{checks.active_shifts} βάρδι{checks.active_shifts === 1 ? 'α' : 'ες'} ακόμα ενεργ{checks.active_shifts === 1 ? 'ή' : 'ές'} θα κλείσουν αυτόματα
</div>
) : (
<div style={{ fontSize: 13, color: '#16a34a', marginTop: 2 }}>Δεν υπάρχουν ενεργές βάρδιες</div>
)}
</div>
</div>
{allGood ? (
<div style={{
border: '1px solid #bbf7d0', borderRadius: 12, padding: '16px 18px',
background: '#f0fdf4', display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 20 }}></span>
<div style={{ fontSize: 14, fontWeight: 600, color: '#166534' }}>
Ο έλεγχος πέρασε η ημέρα είναι έτοιμη να κλείσει.
</div>
</div>
) : hasUnpaid ? (
<div style={{ border: '1px solid #fecaca', borderRadius: 12, padding: '16px 18px', background: '#fff5f5' }}>
<div style={{ fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
<strong>Προσοχή:</strong> Υπάρχουν απλήρωτα αντικείμενα. Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά δεν θα καταγραφούν στις αναφορές.
</div>
</div>
) : (
<div style={{ border: '1px solid #fde68a', borderRadius: 12, padding: '16px 18px', background: '#fffbeb' }}>
<div style={{ fontSize: 13, color: '#78350f', lineHeight: 1.5 }}>
Υπάρχουν ανοιχτά τραπέζια χωρίς απλήρωτα αντικείμενα. Θα κλείσουν αυτόματα.
</div>
</div>
)}
{isOpen && (
<button
onClick={onForceClose}
disabled={closing}
style={{
marginTop: 4, height: 46, borderRadius: 10, border: 'none',
cursor: closing ? 'default' : 'pointer',
background: hasUnpaid ? '#dc2626' : '#111315',
color: 'white', fontSize: 14, fontWeight: 700,
opacity: closing ? 0.7 : 1, transition: 'opacity 150ms',
}}
>
{closing ? 'Κλείσιμο…' : hasUnpaid ? '⚠ Αναγκαστικό Κλείσιμο' : 'Κλείσε Ημέρα'}
</button>
)}
</div>
)
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = false, onDayClosed }) {
const [tab, setTab] = useState('overview')
const [closing, setClosing] = useState(false)
const { data, isLoading, error } = useQuery({
queryKey: ['workday-summary', dayId ?? 'current'],
queryFn: () => client.get('/api/business-day/summary' + (dayId ? `?day_id=${dayId}` : '')).then(r => r.data),
refetchInterval: showCloseAction ? 15_000 : false,
})
async function handleClose(force = false) {
setClosing(true)
try {
await client.post('/api/business-day/close', { force })
onDayClosed?.()
} catch (e) {
const detail = e.response?.data?.detail
if (e.response?.status === 409 && detail?.open_orders) {
setTab('checks')
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
}
} finally {
setClosing(false)
}
}
const tabs = [
{ id: 'overview', label: 'Επισκόπηση' },
{ id: 'waiters', label: 'Προσωπικό', badge: data?.waiters?.length },
{ id: 'zones', label: 'Ζώνες', badge: data?.zones?.length },
{ id: 'printers', label: 'Εκτυπωτές', badge: data?.printers?.length },
...(showCloseAction ? [{
id: 'checks', label: 'Έλεγχοι',
badge: data?.checks ? (data.checks.open_orders + data.checks.active_shifts) : null,
warn: data?.checks?.with_unpaid_items > 0,
}] : []),
]
const isOpen = data?.status === 'open'
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, padding: 16,
}}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'white', borderRadius: 16, width: '100%', maxWidth: 760,
maxHeight: '92vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '20px 24px 0', borderBottom: '1px solid #f0f0ef',
display: 'flex', flexDirection: 'column', gap: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{
width: 36, height: 36, borderRadius: 9, background: '#111315',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 800, color: '#111315' }}>
{showCloseAction ? 'Κλείσιμο Εργάσιμης Ημέρας' : 'Στατιστικά Ημέρας'}
</div>
{data && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
{data.status === 'open'
? <span style={{ color: '#16a34a', fontWeight: 600 }}> Ημέρα Ενεργή</span>
: <span>Κλειστή</span>
}
{' · '}Άνοιγμα {fmtDateTime(data?.opened_at)}
</div>
)}
</div>
<button
onClick={onClose}
style={{
width: 32, height: 32, borderRadius: 8, border: '1px solid #e5e7eb',
background: 'white', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div style={{ display: 'flex', gap: 0, overflowX: 'auto' }}>
{tabs.map(t => (
<TabBtn key={t.id} label={t.label} active={tab === t.id}
onClick={() => setTab(t.id)} badge={t.badge} warn={t.warn} />
))}
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '22px 24px' }}>
{isLoading && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '48px 0' }}>Φόρτωση</div>
)}
{error && (
<div style={{ textAlign: 'center', color: '#dc2626', fontSize: 13, padding: '48px 0' }}>Σφάλμα φόρτωσης δεδομένων</div>
)}
{data && (
<>
{tab === 'overview' && <OverviewTab data={data} />}
{tab === 'waiters' && <WaitersTab data={data} />}
{tab === 'zones' && <ZonesTab data={data} />}
{tab === 'printers' && <PrintersTab data={data} />}
{tab === 'checks' && (
<ChecksTab
data={data}
isOpen={isOpen}
onForceClose={() => handleClose(data.checks.open_orders > 0)}
closing={closing}
/>
)}
</>
)}
</div>
{/* Footer — only shown when in close-flow and not already on checks tab */}
{showCloseAction && data && tab !== 'checks' && (
<div style={{
padding: '14px 24px', borderTop: '1px solid #f0f0ef',
display: 'flex', gap: 10, justifyContent: 'flex-end', background: 'white',
}}>
<button onClick={onClose} style={{
height: 40, padding: '0 18px', borderRadius: 9,
border: '1px solid #e5e7eb', background: 'white',
fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}>Ακύρωση</button>
<button onClick={() => setTab('checks')} style={{
height: 40, padding: '0 20px', borderRadius: 9,
background: '#111315', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Προχωρήστε στους Ελέγχους </button>
</div>
)}
</div>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal'
import { LicenseContext } from '../layouts/AppLayout'
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
import WorkdaySummaryModal from '../components/WorkdaySummaryModal'
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -259,7 +260,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 720,
background: 'white', borderRadius: 20, width: '100%', maxWidth: 800,
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
overflow: 'hidden',
@@ -387,17 +388,17 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
{/* Footer actions */}
{order && (
<div style={{
padding: '14px 22px', borderTop: '1px solid #edeff1',
display: 'flex', gap: 8, flexWrap: 'nowrap', flexShrink: 0,
padding: '12px 22px', borderTop: '1px solid #edeff1',
display: 'flex', gap: 8, flexWrap: 'wrap', flexShrink: 0,
background: '#fafafa', alignItems: 'center',
}}>
{isOpen && activeItems.length > 0 && (
<button
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
style={{
height: 36, padding: '0 16px', borderRadius: 8,
minHeight: 36, padding: '6px 16px', borderRadius: 8,
background: '#3758c9', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
fontSize: 13, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>Πληρωμή όλων</button>
)}
@@ -406,23 +407,23 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer',
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>Κλείσιμο</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #fecaca', background: '#fef2f2',
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer',
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>Ακύρωση</button>
</>
)}
{printers.length > 0 && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexWrap: 'wrap' }}>
<select
value={printerId}
onChange={e => setPrinterId(e.target.value)}
@@ -439,9 +440,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
disabled={!printerId}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 13, fontWeight: 600,
color: '#2b2f33', fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap',
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
}}
>🖨 Εκτύπωση</button>
@@ -450,9 +451,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
<button
onClick={onOpenFull}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer',
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap',
marginLeft: printers.length > 0 ? 0 : 'auto',
}}
>Πλήρης εικόνα </button>
@@ -792,10 +793,111 @@ function RevenueChart({ orders }) {
)
}
// ─── Reservations stub ────────────────────────────────────────────────────────
// ─── Reservations ─────────────────────────────────────────────────────────────
const STATUS_META = {
pending: { label: 'Αναμονή', bg: '#f0f4ff', color: '#3758c9' },
arrived: { label: 'Έφτασε', bg: '#eef7f0', color: '#1f7042' },
cancelled: { label: 'Ακυρώθηκε', bg: '#fef2f2', color: '#dc2626' },
no_show: { label: 'Δεν ήρθε', bg: '#fff7ed', color: '#c2410c' },
}
function ReservationRow({ res, tables, onStatusChange, onEdit }) {
const meta = STATUS_META[res.status] || STATUS_META.pending
const time = new Date(res.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
const dateStr = new Date(res.reserved_for).toLocaleDateString('el-GR', { day: 'numeric', month: 'short' })
const tableLabel = res.table_label || (res.table_id ? `#${res.table_id}` : null)
function ReservationsCard() {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 16px',
borderBottom: '1px solid #f3f4f6',
}}>
{/* Time block */}
<div style={{
minWidth: 44, textAlign: 'center', flexShrink: 0,
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', lineHeight: 1 }}>{time}</div>
<div style={{ fontSize: 10, color: '#8a9099', marginTop: 2 }}>{dateStr}</div>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700, color: '#111315',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{res.guest_name}</div>
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 2, display: 'flex', gap: 6 }}>
<span>👥 {res.party_size}</span>
{tableLabel && <span>🪑 {tableLabel}</span>}
{res.phone && <span>📞 {res.phone}</span>}
</div>
</div>
{/* Status pill */}
<span style={{
padding: '2px 8px', borderRadius: 999, fontSize: 10, fontWeight: 700, flexShrink: 0,
background: meta.bg, color: meta.color,
}}>{meta.label}</span>
{/* Actions */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button onClick={() => onEdit(res)} title="Επεξεργασία" style={{
width: 26, height: 26, borderRadius: 6, border: '1px solid #dfe2e6',
background: 'white', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
{res.status === 'pending' && (
<button onClick={() => onStatusChange(res.id, 'arrived')} title="Έφτασε" style={{
width: 26, height: 26, borderRadius: 6, border: '1px solid #bbf7d0',
background: '#eef7f0', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
)}
{(res.status === 'pending' || res.status === 'no_show') && (
<button onClick={() => onStatusChange(res.id, 'cancelled')} title="Ακύρωση" style={{
width: 26, height: 26, borderRadius: 6, border: '1px solid #fecaca',
background: '#fef2f2', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
)}
</div>
</div>
)
}
function ReservationsCard({ tables, onNew }) {
const [tab, setTab] = useState('upcoming')
const qc = useQueryClient()
const { data: reservations = [], isLoading } = useQuery({
queryKey: ['reservations', tab],
queryFn: () => client.get(`/api/reservations/?tab=${tab}`).then(r => r.data),
refetchInterval: 30_000,
})
const [editTarget, setEditTarget] = useState(null)
const statusMut = useMutation({
mutationFn: ({ id, status }) => client.patch(`/api/reservations/${id}/status`, { status }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['reservations'] })
qc.invalidateQueries({ queryKey: ['tables'] })
},
onError: () => toast.error('Σφάλμα ενημέρωσης'),
})
const tabStyle = (active) => ({
height: 30, padding: '0 14px', borderRadius: 8, border: 'none',
background: active ? '#f0f4ff' : 'transparent',
color: active ? '#3758c9' : '#5a6169',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
})
return (
<>
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
@@ -803,21 +905,300 @@ function ReservationsCard() {
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #edeff1',
padding: '12px 16px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Κρατήσεις σήμερα</div>
<button style={{
<div style={{ display: 'flex', gap: 4 }}>
<button style={tabStyle(tab === 'upcoming')} onClick={() => setTab('upcoming')}>Επερχόμενες</button>
<button style={tabStyle(tab === 'history')} onClick={() => setTab('history')}>Ιστορικό</button>
</div>
<button onClick={onNew} style={{
height: 28, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
color: '#3758c9', fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>+ Νέα</button>
</div>
{isLoading ? (
<div style={{ padding: '24px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Φόρτωση</div>
) : reservations.length === 0 ? (
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
<div style={{ fontSize: 26, marginBottom: 8 }}>📅</div>
{tab === 'upcoming' ? 'Καμία επερχόμενη κράτηση' : 'Κανένα ιστορικό'}
</div>
) : (
<div style={{ maxHeight: 340, overflowY: 'auto' }}>
{reservations.map(r => (
<ReservationRow
key={r.id}
res={r}
tables={tables}
onStatusChange={(id, s) => statusMut.mutate({ id, status: s })}
onEdit={setEditTarget}
/>
))}
</div>
)}
</div>
{editTarget && (
<ReservationModal
tables={tables}
initial={editTarget}
onClose={() => setEditTarget(null)}
onSaved={() => {
setEditTarget(null)
qc.invalidateQueries({ queryKey: ['reservations'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}}
/>
)}
</>
)
}
// ─── New / Edit Reservation Modal ─────────────────────────────────────────────
function toLocalISO(d) {
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`
}
function DateTimePicker({ value, onChange }) {
// value: Date object
const pad = n => String(n).padStart(2, '0')
const toLocal = (d) => {
const y = d.getFullYear()
const mo = pad(d.getMonth() + 1)
const dy = pad(d.getDate())
const h = pad(d.getHours())
const mi = pad(d.getMinutes())
return `${y}-${mo}-${dy}T${h}:${mi}`
}
const fromLocal = (s) => new Date(s)
const quickOffsets = [
{ label: '+30λ', mins: 30 },
{ label: '+1ω', mins: 60 },
{ label: '+2ω', mins: 120 },
{ label: '+3ω', mins: 180 },
{ label: '+5ω', mins: 300 },
{ label: '+1μ', days: 1 },
{ label: '+2μ', days: 2 },
{ label: '+7μ', days: 7 },
]
function applyOffset(offset) {
const base = new Date()
if (offset.mins) base.setMinutes(base.getMinutes() + offset.mins)
else if (offset.days) { base.setDate(base.getDate() + offset.days); base.setSeconds(0, 0) }
onChange(base)
}
return (
<div>
<input
type="datetime-local"
value={toLocal(value)}
onChange={e => onChange(fromLocal(e.target.value))}
style={{
width: '100%', height: 38, borderRadius: 8,
border: '1px solid #dfe2e6', padding: '0 12px',
fontSize: 14, fontFamily: 'inherit', marginBottom: 8,
}}
/>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{quickOffsets.map(o => (
<button key={o.label} type="button" onClick={() => applyOffset(o)} style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1px solid #dfe2e6', background: '#f8f9fa',
fontSize: 12, fontWeight: 600, color: '#374151', cursor: 'pointer',
}}>{o.label}</button>
))}
</div>
</div>
)
}
function GroupedTableSelect({ tables, value, onChange }) {
// Group tables by group_id
const groups = {}
for (const t of tables) {
const key = t.group?.name || 'Χωρίς ζώνη'
if (!groups[key]) groups[key] = []
groups[key].push(t)
}
return (
<select
value={value ?? ''}
onChange={e => onChange(e.target.value ? Number(e.target.value) : null)}
style={{
width: '100%', height: 38, borderRadius: 8,
border: '1px solid #dfe2e6', padding: '0 12px',
fontSize: 14, fontFamily: 'inherit', background: 'white',
}}
>
<option value=""> Χωρίς τραπέζι </option>
{Object.entries(groups).map(([groupName, gtables]) => (
<optgroup key={groupName} label={groupName}>
{gtables.map(t => (
<option key={t.id} value={t.id}>
{t.label || `T${t.number}`}
</option>
))}
</optgroup>
))}
</select>
)
}
function ReservationModal({ tables, initial, onClose, onSaved }) {
const isEdit = !!initial?.id
const [guestName, setGuestName] = useState(initial?.guest_name ?? '')
const [partySize, setPartySize] = useState(initial?.party_size ?? 2)
const [phone, setPhone] = useState(initial?.phone ?? '')
const [email, setEmail] = useState(initial?.email ?? '')
const [note, setNote] = useState(initial?.note ?? '')
const [tableId, setTableId] = useState(initial?.table_id ?? null)
const [reservedFor, setReservedFor] = useState(
initial?.reserved_for ? new Date(initial.reserved_for) : new Date()
)
const saveMut = useMutation({
mutationFn: (payload) => isEdit
? client.patch(`/api/reservations/${initial.id}`, payload)
: client.post('/api/reservations/', payload),
onSuccess: () => { toast.success(isEdit ? 'Αποθηκεύτηκε!' : 'Κράτηση δημιουργήθηκε!'); onSaved() },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function submit(e) {
e.preventDefault()
if (!guestName.trim()) return toast.error('Εισάγετε όνομα')
if (partySize < 1) return toast.error('Αριθμός ατόμων > 0')
saveMut.mutate({
guest_name: guestName.trim(),
party_size: partySize,
phone: phone.trim() || null,
email: email.trim() || null,
note: note.trim() || null,
table_id: tableId,
reserved_for: toLocalISO(reservedFor),
})
}
const labelStyle = { fontSize: 12, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, display: 'block' }
const inputStyle = { width: '100%', height: 38, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box' }
return (
<div
onClick={e => { if (e.target === e.currentTarget) onClose() }}
style={{
position: 'fixed', inset: 0, zIndex: 9000,
background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16,
}}
>
<div style={{
padding: '32px 20px', textAlign: 'center',
color: '#b8bdc4', fontSize: 13,
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
maxHeight: '90vh', overflowY: 'auto',
}}>
<div style={{ fontSize: 28, marginBottom: 8 }}>📅</div>
Σύντομα σύστημα κρατήσεων υπό ανάπτυξη
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 24px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>
{isEdit ? 'Επεξεργασία Κράτησης' : 'Νέα Κράτηση'}
</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: 8, border: '1px solid #edeff1',
background: 'white', fontSize: 16, cursor: 'pointer', lineHeight: 1,
}}>×</button>
</div>
<form onSubmit={submit} style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Name + Party size on same row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
<div>
<label style={labelStyle}>Όνομα</label>
<input
style={inputStyle}
value={guestName}
onChange={e => setGuestName(e.target.value)}
placeholder="Ονοματεπώνυμο πελάτη"
required
/>
</div>
<div>
<label style={labelStyle}>Άτομα</label>
<input
type="number" min={1} max={999}
style={{ ...inputStyle, width: 80 }}
value={partySize}
onChange={e => setPartySize(Number(e.target.value))}
/>
</div>
</div>
{/* Date/time */}
<div>
<label style={labelStyle}>Ημερομηνία & Ώρα</label>
<DateTimePicker value={reservedFor} onChange={setReservedFor} />
</div>
{/* Table */}
<div>
<label style={labelStyle}>Τραπέζι (προαιρετικό)</label>
<GroupedTableSelect tables={tables} value={tableId} onChange={setTableId} />
</div>
{/* Phone + Email */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Τηλέφωνο</label>
<input style={inputStyle} value={phone} onChange={e => setPhone(e.target.value)} placeholder="69xxxxxxxx" />
</div>
<div>
<label style={labelStyle}>Email</label>
<input style={inputStyle} type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="email@..." />
</div>
</div>
{/* Note */}
<div>
<label style={labelStyle}>Σημείωση</label>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Εσωτερική σημείωση για το προσωπικό…"
style={{
width: '100%', minHeight: 70, borderRadius: 8,
border: '1px solid #dfe2e6', padding: '8px 12px',
fontSize: 14, fontFamily: 'inherit', resize: 'vertical', boxSizing: 'border-box',
}}
/>
</div>
{/* Footer */}
<div style={{
display: 'flex', justifyContent: 'flex-end', gap: 8,
paddingTop: 8, borderTop: '1px solid #edeff1',
}}>
<button type="button" onClick={onClose} style={{
height: 38, padding: '0 18px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}>Άκυρο</button>
<button type="submit" disabled={saveMut.isPending} style={{
height: 38, padding: '0 22px', borderRadius: 8,
border: 'none', background: '#3758c9',
color: 'white', fontSize: 14, fontWeight: 700, cursor: 'pointer',
}}>{saveMut.isPending ? 'Αποθήκευση…' : isEdit ? 'Αποθήκευση' : 'Δημιουργία'}</button>
</div>
</form>
</div>
</div>
)
@@ -1133,8 +1514,6 @@ function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onV
export default function DashboardPage() {
const [showStartShift, setShowStartShift] = useState(false)
const [closeDetails, setCloseDetails] = useState(null)
const [forceClosing, setForceClosing] = useState(false)
const [retryingId, setRetryingId] = useState(null)
const [quickView, setQuickView] = useState(null)
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
@@ -1145,6 +1524,9 @@ export default function DashboardPage() {
// Quick message to single waiter
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
// Workday summary modal: 'close' = close-day flow, 'view' = read-only stats
const [workdaySummaryMode, setWorkdaySummaryMode] = useState(null)
const [showNewReservation, setShowNewReservation] = useState(false)
const navigate = useNavigate()
const qc = useQueryClient()
const license = useContext(LicenseContext)
@@ -1211,26 +1593,6 @@ export default function DashboardPage() {
openDayMut.mutate()
}
async function handleCloseDay(force = false) {
setForceClosing(force)
try {
await client.post('/api/business-day/close', { force })
toast.success('Ημέρα έκλεισε!')
setCloseDetails(null)
qc.invalidateQueries({ queryKey: ['business-day'] })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
qc.invalidateQueries({ queryKey: ['tables'] })
qc.invalidateQueries({ queryKey: ['messages-all'] })
} catch (e) {
const detail = e.response?.data?.detail
if (e.response?.status === 409 && detail?.open_orders) setCloseDetails(detail)
else toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
} finally {
setForceClosing(false)
}
}
async function handleEndShiftConfirm(notes) {
if (!endShiftTarget) return
setEndShiftBusy(true)
@@ -1375,11 +1737,18 @@ export default function DashboardPage() {
)}
</button>
{isOpen ? (
<button onClick={() => handleCloseDay(false)} style={{
<>
<button onClick={() => setWorkdaySummaryMode('view')} style={{
height: 38, padding: '0 14px', borderRadius: 10,
background: 'white', border: '1px solid #dfe2e6', color: '#374151',
fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>📊 Στατιστικά Ημέρας</button>
<button onClick={() => setWorkdaySummaryMode('close')} style={{
height: 38, padding: '0 18px', borderRadius: 10,
background: '#dc2626', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Κλείσιμο Ημέρας</button>
</>
) : (
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
height: 38, padding: '0 18px', borderRadius: 10,
@@ -1515,7 +1884,7 @@ export default function DashboardPage() {
{/* ── Right column — Reservations + Messages ───────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<ReservationsCard />
<ReservationsCard tables={tables} onNew={() => setShowNewReservation(true)} />
<MessagesCard waiters={allWaiters} tables={tables} />
</div>
</div>
@@ -1555,12 +1924,31 @@ export default function DashboardPage() {
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
/>
)}
{closeDetails && (
<CloseConfirmModal
details={closeDetails}
onClose={() => setCloseDetails(null)}
onConfirm={() => handleCloseDay(true)}
busy={forceClosing}
{workdaySummaryMode && (
<WorkdaySummaryModal
showCloseAction={workdaySummaryMode === 'close'}
onClose={() => setWorkdaySummaryMode(null)}
onDayClosed={() => {
toast.success('Ημέρα έκλεισε!')
qc.invalidateQueries({ queryKey: ['business-day'] })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
qc.invalidateQueries({ queryKey: ['tables'] })
qc.invalidateQueries({ queryKey: ['messages-all'] })
setWorkdaySummaryMode(null)
}}
/>
)}
{showNewReservation && (
<ReservationModal
tables={tables}
initial={null}
onClose={() => setShowNewReservation(false)}
onSaved={() => {
setShowNewReservation(false)
qc.invalidateQueries({ queryKey: ['reservations'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}}
/>
)}
{quickView && (

View File

@@ -636,7 +636,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
}
// ── Bulk bar ──────────────────────────────────────────────────────────────────
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 24px 0', padding: '10px 14px', background: '#111827', color: '#fff', borderRadius: 10, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
@@ -645,7 +645,7 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
</button>
<span style={{ fontSize: 13, fontWeight: 500 }}>{count} επιλεγμένα</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{[
{ label: 'Διαθέσιμα', icon: 'eye', action: onAvail },
{ label: 'Μη διαθέσιμα', icon: 'eyeOff', action: onUnavail },
@@ -659,6 +659,111 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
{label}
</button>
))}
<div style={{ width: 1, background: 'rgba(255,255,255,0.15)', margin: '4px 2px' }} />
<button onClick={onSetPrinterZone}
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
className="hover:bg-white/10"
>
<Icon name="folder" size={13} />
Ζώνη εκτύπωσης
</button>
<button onClick={onMoveToCategory}
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
className="hover:bg-white/10"
>
<Icon name="move" size={13} />
Μετακίνηση
</button>
</div>
</div>
)
}
// ── Bulk: Set Printer Zone modal ───────────────────────────────────────────────
function BulkSetPrinterZoneModal({ count, printers, onClose, onConfirm }) {
const zones = [...new Map(printers.map(p => [p.id, p])).values()]
const [selectedId, setSelectedId] = useState(zones[0]?.id ?? '')
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 380, padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνη εκτύπωσης</h2>
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
Θα οριστεί σε <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
</p>
{zones.length === 0 ? (
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν εκτυπωτές.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{zones.map(z => (
<label key={z.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${selectedId === z.id ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: selectedId === z.id ? '#eff6ff' : '#fff' }}>
<input type="radio" name="zone" value={z.id} checked={selectedId === z.id} onChange={() => setSelectedId(z.id)} style={{ display: 'none' }} />
<span style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selectedId === z.id ? '#3b82f6' : '#d1d5db'}`, background: selectedId === z.id ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{selectedId === z.id && <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#fff' }} />}
</span>
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
</label>
))}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
<button
onClick={() => onConfirm(selectedId)}
disabled={!selectedId}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
>
Εφαρμογή
</button>
</div>
</div>
</div>
)
}
// ── Bulk: Move to Category modal ───────────────────────────────────────────────
function BulkMoveToCategoryModal({ count, categories, onClose, onConfirm }) {
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const [selectedId, setSelectedId] = useState('')
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 400, padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Μετακίνηση σε κατηγορία</h2>
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
Θα μετακινηθούν <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
</p>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 20 }}>
{topLevel.map(cat => {
const subs = categories.filter(c => c.parent_id === cat.id).sort((a, b) => a.sort_order - b.sort_order)
return (
<div key={cat.id}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', border: `1.5px solid ${selectedId === cat.id ? cat.color || '#3b82f6' : '#e5e7eb'}`, borderRadius: 9, cursor: 'pointer', background: selectedId === cat.id ? '#f8faff' : '#fff' }}>
<input type="radio" name="cat" value={cat.id} checked={selectedId === cat.id} onChange={() => setSelectedId(cat.id)} style={{ display: 'none' }} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cat.color || '#94a3b8', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: '#111827', flex: 1 }}>{cat.name}</span>
{selectedId === cat.id && <Icon name="check" size={14} className="text-blue-500" />}
</label>
{subs.map(sub => (
<label key={sub.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px 8px 28px', border: `1.5px solid ${selectedId === sub.id ? sub.color || '#3b82f6' : '#e5e7eb'}`, borderRadius: 8, cursor: 'pointer', background: selectedId === sub.id ? '#f8faff' : '#fff', marginTop: 3 }}>
<input type="radio" name="cat" value={sub.id} checked={selectedId === sub.id} onChange={() => setSelectedId(sub.id)} style={{ display: 'none' }} />
<span style={{ width: 7, height: 7, borderRadius: '50%', background: sub.color || '#94a3b8', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 500, color: '#374151', flex: 1 }}>{sub.name}</span>
{selectedId === sub.id && <Icon name="check" size={13} className="text-blue-500" />}
</label>
))}
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
<button
onClick={() => onConfirm(selectedId)}
disabled={!selectedId}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
>
Μετακίνηση
</button>
</div>
</div>
</div>
)
@@ -938,6 +1043,8 @@ export default function ProductsTab() {
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
// local override of group display order (array of group keys), reset when scope changes
const [localGroupOrder, setLocalGroupOrder] = useState(null)
const [bulkPrinterZoneModal, setBulkPrinterZoneModal] = useState(false)
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
// drag state for group headers
const groupDragId = useRef(null)
@@ -1163,6 +1270,26 @@ export default function ProductsTab() {
setConfirmDelete(null); invalidate(); clearSelect()
}
async function handleBulkSetPrinterZone(printerId) {
const ids = [...selected]
setBulkPrinterZoneModal(false)
try {
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { printer_zone_id: printerId })))
toast.success(`Ζώνη εκτύπωσης ορίστηκε σε ${ids.length} προϊόντα`)
invalidate(); clearSelect()
} catch { toast.error('Σφάλμα') }
}
async function handleBulkMoveToCategory(categoryId) {
const ids = [...selected]
setBulkMoveCatModal(false)
try {
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { category_id: categoryId })))
toast.success(`${ids.length} προϊόντα μετακινήθηκαν`)
invalidate(); clearSelect()
} catch { toast.error('Σφάλμα') }
}
function handleConfirmDelete() {
if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
@@ -1345,6 +1472,8 @@ export default function ProductsTab() {
onAvail={() => bulkAction('available')}
onUnavail={() => bulkAction('unavailable')}
onArchive={() => bulkAction('archive')}
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
onMoveToCategory={() => setBulkMoveCatModal(true)}
/>
)}
@@ -1500,6 +1629,26 @@ export default function ProductsTab() {
onCancel={() => setConfirmDelete(null)}
/>
)}
{/* Bulk: set printer zone modal */}
{bulkPrinterZoneModal && (
<BulkSetPrinterZoneModal
count={selected.size}
printers={printers}
onClose={() => setBulkPrinterZoneModal(false)}
onConfirm={handleBulkSetPrinterZone}
/>
)}
{/* Bulk: move to category modal */}
{bulkMoveCatModal && (
<BulkMoveToCategoryModal
count={selected.size}
categories={categories}
onClose={() => setBulkMoveCatModal(false)}
onConfirm={handleBulkMoveToCategory}
/>
)}
</div>
)
}

View File

@@ -267,7 +267,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
<div>
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
<p className="text-sm text-gray-500 mt-1">
Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
Τραπέζι {order.table_name || order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
</p>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
@@ -30,6 +30,13 @@ const DIVIDER_OPTIONS = [
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
]
const DOT_STYLE_OPTIONS = [
{ value: 'dot_space', label: 'Τελεία + κενό ( . )', preview: '. . . . .' },
{ value: 'dot', label: 'Τελείες ( . )', preview: '.........' },
{ value: 'dash_space', label: 'Παύλα + κενό ( - )', preview: '- - - - -' },
{ value: 'underscore', label: 'Κάτω παύλα ( _ )', preview: '_________' },
]
const FONT_DEFAULTS = {
'print.font_order_number': '48:1:0',
'print.font_meta': '0:0:0',
@@ -41,6 +48,7 @@ const FONT_DEFAULTS = {
'print.font_item_note': '0:0:0',
'print.font_order_note': '0:1:0',
'print.divider_style': 'dash',
'print.dot_style': 'dot_space:0:0',
'print.ticket_mode': 'detailed',
'print.beep_on_ticket': 'true',
'print.beep_pattern': 'double',
@@ -231,6 +239,100 @@ function DividerRow({ value, onChange, isPending }) {
)
}
// ── Dot style row ─────────────────────────────────────────────────────────
function decodeDotStyle(val) {
if (!val) return { style: 'dot_space', size: '0', bold: false }
const [style, size, bold] = val.split(':')
return { style: style ?? 'dot_space', size: size ?? '0', bold: bold === '1' }
}
function encodeDotStyle(style, size, bold) {
return `${style}:${size}:${bold ? '1' : '0'}`
}
function DotStyleRow({ value, onChange, isPending }) {
const { style, size, bold } = decodeDotStyle(value)
const selected = DOT_STYLE_OPTIONS.find(o => o.value === style) ?? DOT_STYLE_OPTIONS[0]
const s = sizeStyle[size] ?? sizeStyle['0']
function handleStyle(e) { onChange('print.dot_style', encodeDotStyle(e.target.value, size, bold)) }
function handleSize(e) { onChange('print.dot_style', encodeDotStyle(style, e.target.value, bold)) }
function handleBold() { onChange('print.dot_style', encodeDotStyle(style, size, !bold)) }
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 20px 10px 36px',
borderBottom: '1px solid #f4f4f2',
background: '#fafafa',
}}>
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}></span>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
Στυλ Dots
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>Χαρακτήρας · μέγεθος · έντονο για τη γραμμή dot-leader</span>
</div>
{/* Style dropdown */}
<select
value={style}
onChange={handleStyle}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{DOT_STYLE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* Size dropdown */}
<select
value={size}
onChange={handleSize}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{FONT_SIZE_OPTIONS.map(o => (
<option key={o.size} value={o.size}>{o.label}</option>
))}
</select>
{/* Bold toggle */}
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
{/* Preview */}
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
<span style={{
color: '#f5f5f5',
fontFamily: 'monospace',
fontSize: s.fontSize,
fontWeight: bold ? 800 : 400,
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
transformOrigin: 'center',
whiteSpace: 'nowrap',
display: 'block',
}}>
Esp {selected.preview} x2
</span>
</div>
</div>
)
}
// ── Ticket mode section ────────────────────────────────────────────────────
function TicketModeSection({ value, onChange, isPending, printers }) {
const [selectedPrinter, setSelectedPrinter] = useState(null)
@@ -535,7 +637,7 @@ function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }
// ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
@@ -567,6 +669,11 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΧΑΡΑΚΤ./ΓΡΑΜΜΗ</label>
<input value={form.line_width} onChange={e => set('line_width', parseInt(e.target.value) || 48)}
type="number" min={20} max={80} style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
style={btnPrimary}>Αποθήκευση</button>
@@ -631,6 +738,7 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
{printer.ip_address}:{printer.port}
</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.line_width ?? 48} χαρ.</span>
</div>
<span style={{
@@ -844,15 +952,23 @@ export default function PrintFontsTab() {
{FONT_GROUPS.map(group => (
<div key={group.group}>
<SubgroupHeader label={group.group} />
{group.fields.map((field, idx) => (
{group.fields.map((field) => (
<React.Fragment key={field.key}>
<FontRow
key={field.key}
field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
nested={group.fields.length > 1}
/>
{field.key === 'print.font_ingredient' && (
<DotStyleRow
value={val('print.dot_style')}
onChange={handleChange}
isPending={updateMut.isPending}
/>
)}
</React.Fragment>
))}
</div>
))}

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
@@ -46,15 +46,30 @@ function PinInput({ value, onChange }) {
onChange(value + d)
}
return (
<div className="space-y-3">
<div className="flex justify-center gap-2">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10 }}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border-2 ${i < value.length ? 'bg-primary-700 border-primary-700' : 'border-gray-300'}`} />
<div key={i} style={{
width: 13, height: 13, borderRadius: '50%',
border: `2px solid ${i < value.length ? '#111827' : '#d1d5db'}`,
background: i < value.length ? '#111827' : 'transparent',
transition: 'all 150ms',
}} />
))}
</div>
<div className="grid grid-cols-3 gap-2">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, width: '100%' }}>
{DIGITS.map((d, i) => (
<button key={i} type="button" onClick={() => press(d)} disabled={d === ''} className={`h-11 rounded-xl text-lg font-semibold transition-colors ${d === '' ? 'invisible' : d === '⌫' ? 'bg-gray-100 hover:bg-gray-200 text-gray-600' : 'bg-gray-100 hover:bg-primary-100 text-gray-800'}`}>
<button
key={i} type="button" onClick={() => press(d)} disabled={d === ''}
style={{
height: 46, borderRadius: 10, fontSize: 18, fontWeight: 600, border: 'none',
cursor: d === '' ? 'default' : 'pointer',
background: d === '' ? 'transparent' : d === '⌫' ? '#f3f4f6' : '#f8fafc',
color: d === '⌫' ? '#6b7280' : '#111827',
transition: 'background 100ms',
visibility: d === '' ? 'hidden' : 'visible',
}}
>
{d}
</button>
))}
@@ -63,12 +78,109 @@ function PinInput({ value, onChange }) {
)
}
// ── Actions dropdown menu ─────────────────────────────────────────────────────
function ActionsMenu({ waiter, onEdit, onZones, onResetPin, onBlock, onDelete }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
if (!open) return
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const items = [
{ label: 'Επεξεργασία', icon: editIcon, onClick: onEdit },
waiter.role === 'waiter' && { label: 'Πρόσβαση σε Ζώνες', icon: zonesIcon, onClick: onZones },
{ label: 'Επαναφορά PIN', icon: pinIcon, onClick: onResetPin },
{ label: waiter.is_active ? 'Μπλοκάρισμα' : 'Ξεμπλοκάρισμα', icon: blockIcon, onClick: onBlock, color: waiter.is_active ? '#f97316' : '#22c55e' },
{ label: 'Διαγραφή', icon: trashIcon, onClick: onDelete, color: '#ef4444' },
].filter(Boolean)
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(v => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 13, padding: '0 12px', height: 34, borderRadius: 8,
border: '1px solid #e5e7eb', background: open ? '#f3f4f6' : '#fff',
color: '#374151', fontWeight: 500, cursor: 'pointer',
transition: 'background 120ms',
}}
>
Ενέργειες
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 100,
background: '#fff', border: '1px solid #e5e7eb',
borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
minWidth: 200, overflow: 'hidden', padding: '4px 0',
}}>
{items.map((item, i) => (
<button
key={i}
onClick={() => { setOpen(false); item.onClick() }}
style={{
display: 'flex', alignItems: 'center', gap: 10,
width: '100%', padding: '9px 14px', border: 'none',
background: 'transparent', cursor: 'pointer', textAlign: 'left',
fontSize: 13.5, fontWeight: 500,
color: item.color || '#111827',
transition: 'background 80ms',
}}
onMouseEnter={e => e.currentTarget.style.background = item.color ? `${item.color}10` : '#f9fafb'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<span style={{ color: item.color || '#6b7280', display: 'flex', flexShrink: 0 }}>{item.icon}</span>
{item.label}
</button>
))}
</div>
)}
</div>
)
}
const editIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
)
const zonesIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
)
const pinIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)
const blockIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
)
const trashIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" /><path d="M8 6V4h8v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
)
// ── ZoneModal ─────────────────────────────────────────────────────────────────
function ZoneModal({ waiter, groups, onClose }) {
const qc = useQueryClient()
// Derive initial state from waiter's zone_assignments
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
const [allZones, setAllZones] = useState(hasAllZones)
const [selected, setSelected] = useState(new Set(assignedIds))
@@ -79,91 +191,93 @@ function ZoneModal({ waiter, groups, onClose }) {
})
function toggleGroup(gid) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(gid)) next.delete(gid); else next.add(gid)
return next
})
}
function save() {
if (allZones) {
saveZones.mutate({ all_zones: true, group_ids: [] })
} else {
saveZones.mutate({ all_zones: false, group_ids: [...selected] })
}
setSelected(prev => { const next = new Set(prev); next.has(gid) ? next.delete(gid) : next.add(gid); return next })
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Ζώνες {waiter.username}</h2>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={allZones}
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }}
/>
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={allZones}
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }} />
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
</label>
{!allZones && (
<div className="space-y-2 max-h-60 overflow-y-auto">
{groups.length === 0 && (
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
)}
{groups.length === 0 && <p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>}
{groups.map(g => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={selected.has(g.id)}
onChange={() => toggleGroup(g.id)}
/>
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={selected.has(g.id)} onChange={() => toggleGroup(g.id)} />
<span className="text-gray-700">{g.name}</span>
{g.color && (
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
)}
{g.color && <span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />}
</label>
))}
</div>
)}
{!allZones && selected.size === 0 && (
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
</p>
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.</p>
)}
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
Αποθήκευση
</button>
<button onClick={() => saveZones.mutate(allZones ? { all_zones: true, group_ids: [] } : { all_zones: false, group_ids: [...selected] })}
disabled={saveZones.isPending} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div>
</div>
</div>
)
}
// ── Shared form primitives ────────────────────────────────────────────────────
function FormField({ label, required, desc, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151' }}>
{label}{required && <span style={{ color: '#ef4444', marginLeft: 2 }}>*</span>}
</label>
{children}
{desc && <p style={{ margin: 0, fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>{desc}</p>}
</div>
)
}
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter', pin: '' }
function SectionLabel({ children }) {
return (
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#9ca3af', marginBottom: 10 }}>
{children}
</div>
)
}
function InfoBlock({ label, value, valueColor }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{ fontSize: 10.5, fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</span>
<span style={{ fontSize: 13.5, fontWeight: 500, color: valueColor || '#111827' }}>{value || '—'}</span>
</div>
)
}
const inputStyle = {
width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb', borderRadius: 7,
fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box',
}
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '' }
// ── Main page ─────────────────────────────────────────────────────────────────
export default function WaitersPage() {
const qc = useQueryClient()
const [addModal, setAddModal] = useState(false)
const [pinModal, setPinModal] = useState(null) // waiter id
const [zoneModal, setZoneModal] = useState(null) // waiter object
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
const [pinModal, setPinModal] = useState(null)
const [zoneModal, setZoneModal] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null)
const [newPin, setNewPin] = useState('')
const [newForm, setNewForm] = useState(EMPTY_FORM)
const [newAvatarFile, setNewAvatarFile] = useState(null)
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
const [editModal, setEditModal] = useState(null) // waiter object
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter' })
const [editModal, setEditModal] = useState(null)
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter' })
const avatarInputRef = useRef(null)
const newAvatarInputRef = useRef(null)
@@ -171,7 +285,6 @@ export default function WaitersPage() {
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
})
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
@@ -191,10 +304,7 @@ export default function WaitersPage() {
},
onSuccess: () => {
toast.success('Σερβιτόρος δημιουργήθηκε')
setAddModal(false)
setNewForm(EMPTY_FORM)
setNewAvatarFile(null)
setNewAvatarPreview(null)
setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null)
invalidate()
},
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
@@ -208,31 +318,22 @@ export default function WaitersPage() {
const uploadAvatar = useMutation({
mutationFn: ({ id, file }) => {
const fd = new FormData()
fd.append('file', file)
const fd = new FormData(); fd.append('file', file)
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
},
onSuccess: (res) => {
toast.success('Avatar ανέβηκε')
setEditModal(res.data)
invalidate()
},
onSuccess: (res) => { toast.success('Avatar ανέβηκε'); setEditModal(res.data); invalidate() },
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
})
const deleteAvatar = useMutation({
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
onSuccess: (res) => {
toast.success('Avatar αφαιρέθηκε')
setEditModal(res.data)
invalidate()
},
onSuccess: (res) => { toast.success('Avatar αφαιρέθηκε'); setEditModal(res.data); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const toggleBlock = useMutation({
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
onSuccess: () => { invalidate() },
onSuccess: () => invalidate(),
onError: () => toast.error('Σφάλμα'),
})
@@ -250,9 +351,13 @@ export default function WaitersPage() {
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
function openEdit(w) {
setEditModal(w)
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter' })
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Toolbar */}
<div className="flex items-center justify-end gap-2 border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέος σερβιτόρος</Button>
</div>
@@ -265,6 +370,7 @@ export default function WaitersPage() {
{waiters.map(w => (
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
<WaiterAvatar waiter={w} size={44} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
@@ -274,202 +380,84 @@ export default function WaitersPage() {
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
{w.role === 'waiter' && (
<p className="text-xs text-gray-400 mt-0.5">
{w.zone_assignments.length === 0
? 'Χωρίς ζώνες'
: w.zone_assignments.some(z => z.group_id === null)
? 'Όλες οι ζώνες'
{w.zone_assignments.length === 0 ? 'Χωρίς ζώνες'
: w.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
</p>
)}
</div>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
{/* Active status badge — only this stays inline */}
<span style={{
fontSize: 11.5, fontWeight: 600, padding: '3px 9px', borderRadius: 999,
background: w.is_active ? '#dcfce7' : '#fee2e2',
color: w.is_active ? '#15803d' : '#dc2626',
flexShrink: 0,
}}>
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
</span>
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', role: w.role || 'waiter' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{w.role === 'waiter' && (
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
)}
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
</button>
<button onClick={() => setConfirmDelete(w.id)} className="btn btn-ghost text-sm px-2 py-1.5 min-h-0 h-9 text-red-500 hover:bg-red-50">🗑</button>
{/* Single actions dropdown */}
<ActionsMenu
waiter={w}
onEdit={() => openEdit(w)}
onZones={() => setZoneModal(w)}
onResetPin={() => setPinModal(w.id)}
onBlock={() => toggleBlock.mutate(w.id)}
onDelete={() => setConfirmDelete(w.id)}
/>
</div>
))}
</div>
{/* Add waiter modal */}
{/* Add modal */}
{addModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
{/* Avatar picker */}
<div className="flex items-center gap-4">
{newAvatarPreview ? (
<img src={newAvatarPreview} alt="preview" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 28, color: '#9ca3af' }}>👤</span>
</div>
)}
<div className="flex flex-col gap-2">
<input
ref={newAvatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) {
setNewAvatarFile(file)
setNewAvatarPreview(URL.createObjectURL(file))
}
e.target.value = ''
}}
<AddWaiterModal
form={newForm} setForm={setNewForm}
avatarFile={newAvatarFile} avatarPreview={newAvatarPreview}
setAvatarFile={setNewAvatarFile} setAvatarPreview={setNewAvatarPreview}
avatarInputRef={newAvatarInputRef}
isPending={createWaiter.isPending}
onClose={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }}
onSubmit={() => createWaiter.mutate({
username: newForm.username, full_name: newForm.full_name || null,
nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null,
email: newForm.email || null, note: newForm.note || null,
pin: newForm.pin, role: newForm.role, is_active: true,
})}
/>
<button onClick={() => newAvatarInputRef.current?.click()} type="button" className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8">
{newAvatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
</button>
{newAvatarPreview && (
<button type="button" onClick={() => { setNewAvatarFile(null); setNewAvatarPreview(null) }} className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50">
Αφαίρεση
</button>
)}
</div>
</div>
<div>
<label className="label">Πλήρες όνομα *</label>
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Παρατσούκλι (nickname) *</label>
<input className="input" placeholder="π.χ. Γιώργος" value={newForm.nickname} onChange={e => setNewForm(f => ({ ...f, nickname: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div>
<label className="label">Όνομα χρήστη *</label>
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
</div>
<div>
<label className="label">Ρόλος *</label>
<select className="input" value={newForm.role} onChange={e => setNewForm(f => ({ ...f, role: e.target.value }))}>
<option value="waiter">Σερβιτόρος</option>
<option value="manager">Διαχειριστής</option>
</select>
</div>
<div>
<label className="label mb-2">PIN *</label>
<PinInput value={newForm.pin} onChange={pin => setNewForm(f => ({ ...f, pin }))} />
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
disabled={createWaiter.isPending || !newForm.username.trim() || !newForm.full_name.trim() || !newForm.nickname.trim() || newForm.pin.length < 4}
className="flex-1 btn btn-primary"
>
{createWaiter.isPending ? 'Δημιουργία…' : 'Δημιουργία'}
</button>
</div>
</div>
</div>
)}
{/* Edit profile modal */}
{/* Edit modal */}
{editModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
<h2 className="font-bold text-gray-800">Επεξεργασία {editModal.full_name || editModal.username}</h2>
{/* Avatar section */}
<div className="flex items-center gap-4">
<WaiterAvatar waiter={editModal} size={64} />
<div className="flex flex-col gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) uploadAvatar.mutate({ id: editModal.id, file })
e.target.value = ''
}}
<EditWaiterModal
waiter={editModal} form={editForm} setForm={setEditForm}
avatarInputRef={avatarInputRef}
isPending={updateWaiter.isPending}
isUploadPending={uploadAvatar.isPending}
isDeletePending={deleteAvatar.isPending}
onClose={() => setEditModal(null)}
onSubmit={() => updateWaiter.mutate({
id: editModal.id,
username: editForm.username.trim() || undefined,
full_name: editForm.full_name || null, nickname: editForm.nickname || null,
mobile_phone: editForm.mobile_phone || null, email: editForm.email || null,
note: editForm.note || null, role: editForm.role,
})}
onUploadAvatar={file => uploadAvatar.mutate({ id: editModal.id, file })}
onDeleteAvatar={() => deleteAvatar.mutate(editModal.id)}
/>
<button
onClick={() => avatarInputRef.current?.click()}
disabled={uploadAvatar.isPending}
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
>
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
</button>
{editModal.avatar_url && (
<button
onClick={() => deleteAvatar.mutate(editModal.id)}
disabled={deleteAvatar.isPending}
className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50"
>
Αφαίρεση
</button>
)}
</div>
</div>
<div>
<label className="label">Πλήρες όνομα *</label>
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Παρατσούκλι (nickname) *</label>
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div>
<label className="label">Όνομα χρήστη *</label>
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} />
</div>
<div>
<label className="label">Ρόλος *</label>
<select className="input" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}>
<option value="waiter">Σερβιτόρος</option>
<option value="manager">Διαχειριστής</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null, role: editForm.role })}
disabled={updateWaiter.isPending || !editForm.username.trim() || !editForm.full_name.trim() || !editForm.nickname.trim()}
className="flex-1 btn btn-primary"
>
{updateWaiter.isPending ? 'Αποθήκευση…' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)}
{/* Reset PIN modal */}
{pinModal !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
<h2 className="font-bold text-gray-800">Reset PIN {waiters.find(w => w.id === pinModal)?.username}</h2>
<h2 className="font-bold text-gray-800">Επαναφορά PIN {waiters.find(w => w.id === pinModal)?.username}</h2>
<PinInput value={newPin} onChange={setNewPin} />
<div className="flex gap-3 pt-2">
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })}
disabled={newPin.length < 4}
className="flex-1 btn btn-primary"
>
<button onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })} disabled={newPin.length < 4} className="flex-1 btn btn-primary">
Αποθήκευση
</button>
</div>
@@ -495,3 +483,280 @@ export default function WaitersPage() {
</div>
)
}
// ── Add Waiter Modal — two-column ─────────────────────────────────────────────
function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFile, setAvatarPreview, avatarInputRef, isPending, onClose, onSubmit }) {
const f = (field, val) => setForm(p => ({ ...p, [field]: val }))
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim() && form.pin.length >= 4
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50, padding: 24,
}}>
<div style={{
background: '#fff', borderRadius: 20, boxShadow: '0 24px 72px rgba(0,0,0,0.18)',
width: '100%', maxWidth: 980,
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{ padding: '18px 26px 14px', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
{avatarPreview
? <img src={avatarPreview} alt="preview" style={{ width: 48, height: 48, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
: <div style={{ width: 48, height: 48, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 22, color: '#9ca3af' }}>👤</span>
</div>
}
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: 0, fontWeight: 700, fontSize: 16, color: '#111827' }}>Νέος σερβιτόρος</h2>
<p style={{ margin: '2px 0 0', fontSize: 12, color: '#9ca3af' }}>Συμπληρώστε τα στοιχεία του εργαζόμενου</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input ref={avatarInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => {
const file = e.target.files?.[0]
if (file) { setAvatarFile(file); setAvatarPreview(URL.createObjectURL(file)) }
e.target.value = ''
}} />
<button onClick={() => avatarInputRef.current?.click()} type="button"
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 7, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
{avatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
</button>
{avatarPreview && (
<button type="button" onClick={() => { setAvatarFile(null); setAvatarPreview(null) }}
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #fecaca', background: '#fff5f5', borderRadius: 7, cursor: 'pointer', color: '#ef4444', fontWeight: 500 }}>
Αφαίρεση
</button>
)}
</div>
</div>
{/* Body — three columns, scrollable */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', flex: 1, minHeight: 0, overflowY: 'auto' }}>
{/* Column 1: Identity + Account */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
<div>
<SectionLabel>Ταυτότητα</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Πλήρες Όνομα" required desc="Εμφανίζεται στα στατιστικά.">
<input style={inputStyle} placeholder="π.χ. Γιώργος Παπαδόπουλος" value={form.full_name} onChange={e => f('full_name', e.target.value)} autoFocus />
</FormField>
<FormField label="Παρατσούκλι" required desc="Εμφανίζεται στην εφαρμογή των τηλεφώνων.">
<input style={inputStyle} placeholder="π.χ. Γιώργος" value={form.nickname} onChange={e => f('nickname', e.target.value)} />
</FormField>
</div>
</div>
<div>
<SectionLabel>Λογαριασμός</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Όνομα χρήστη" required desc="Χρησιμοποιείται για login.">
<input style={inputStyle} placeholder="π.χ. giorgos" value={form.username} onChange={e => f('username', e.target.value)} />
</FormField>
<FormField label="Ρόλος" required desc="Καθορίζει τα δικαιώματα πρόσβασης.">
<select style={inputStyle} value={form.role} onChange={e => f('role', e.target.value)}>
<option value="waiter">Σερβιτόρος</option>
<option value="manager">Διαχειριστής</option>
</select>
</FormField>
</div>
</div>
</div>
{/* Column 2: Contact info */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
<div>
<SectionLabel>Επικοινωνία</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Κινητό τηλέφωνο" desc="Εσωτερική αναφορά μόνο.">
<input style={inputStyle} placeholder="π.χ. 6901234567" value={form.mobile_phone} onChange={e => f('mobile_phone', e.target.value)} />
</FormField>
<FormField label="Email" desc="Εσωτερική χρήση μόνο.">
<input style={inputStyle} type="email" placeholder="π.χ. giorgos@restaurant.gr" value={form.email} onChange={e => f('email', e.target.value)} />
</FormField>
<FormField label="Σημείωση" desc="Οποιαδήποτε εσωτερική σημείωση.">
<textarea style={{ ...inputStyle, resize: 'none' }} placeholder="π.χ. Μερική απασχόληση" value={form.note} onChange={e => f('note', e.target.value)} rows={4} />
</FormField>
</div>
</div>
</div>
{/* Column 3: PIN */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 12, background: '#fafafa' }}>
<SectionLabel>Κωδικός PIN</SectionLabel>
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
</p>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 8 }}>
<div style={{ width: '100%', maxWidth: 220 }}>
<PinInput value={form.pin} onChange={pin => f('pin', pin)} />
{form.pin.length > 0 && form.pin.length < 4 && (
<p style={{ textAlign: 'center', fontSize: 11.5, color: '#f59e0b', marginTop: 10 }}>
Χρειάζονται {4 - form.pin.length} ψηφία ακόμη
</p>
)}
{form.pin.length >= 4 && (
<p style={{ textAlign: 'center', fontSize: 11.5, color: '#22c55e', marginTop: 10, fontWeight: 600 }}>
PIN ορίστηκε
</p>
)}
</div>
</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: '14px 26px', borderTop: '1px solid #f3f4f6', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 13.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
Ακύρωση
</button>
<button onClick={onSubmit} disabled={isPending || !canSubmit}
style={{ padding: '8px 20px', border: 'none', background: canSubmit ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed' }}>
{isPending ? 'Δημιουργία…' : 'Δημιουργία σερβιτόρου'}
</button>
</div>
</div>
</div>
)
}
// ── Edit Waiter Modal — three-column ──────────────────────────────────────────
function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isUploadPending, isDeletePending, onClose, onSubmit, onUploadAvatar, onDeleteAvatar }) {
const f = (field, val) => setForm(p => ({ ...p, [field]: val }))
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim()
const zoneLabel = !waiter.zone_assignments?.length ? 'Χωρίς ζώνες'
: waiter.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
: `${waiter.zone_assignments.length} ζών${waiter.zone_assignments.length === 1 ? 'η' : 'ες'}`
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50, padding: 24,
}}>
<div style={{
background: '#fff', borderRadius: 20, boxShadow: '0 24px 72px rgba(0,0,0,0.18)',
width: '100%', maxWidth: 980,
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{ padding: '18px 26px 14px', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
<WaiterAvatar waiter={waiter} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: 0, fontWeight: 700, fontSize: 16, color: '#111827' }}>
Επεξεργασία {waiter.full_name || waiter.username}
</h2>
<p style={{ margin: '2px 0 0', fontSize: 12, color: '#9ca3af' }}>Ενημέρωση στοιχείων εργαζόμενου</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input ref={avatarInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const file = e.target.files?.[0]; if (file) onUploadAvatar(file); e.target.value = '' }} />
<button onClick={() => avatarInputRef.current?.click()} disabled={isUploadPending}
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 7, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
{isUploadPending ? 'Μεταφόρτωση…' : waiter.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
</button>
{waiter.avatar_url && (
<button onClick={onDeleteAvatar} disabled={isDeletePending}
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #fecaca', background: '#fff5f5', borderRadius: 7, cursor: 'pointer', color: '#ef4444', fontWeight: 500 }}>
Αφαίρεση
</button>
)}
</div>
</div>
{/* Body — three columns, scrollable */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', flex: 1, minHeight: 0, overflowY: 'auto' }}>
{/* Column 1: Identity + Account */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
<div>
<SectionLabel>Ταυτότητα</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Πλήρες Όνομα" required desc="Εμφανίζεται στα στατιστικά.">
<input style={inputStyle} value={form.full_name} onChange={e => f('full_name', e.target.value)} autoFocus />
</FormField>
<FormField label="Παρατσούκλι" required desc="Εμφανίζεται στην εφαρμογή των τηλεφώνων.">
<input style={inputStyle} placeholder="π.χ. Γιώργος" value={form.nickname} onChange={e => f('nickname', e.target.value)} />
</FormField>
</div>
</div>
<div>
<SectionLabel>Λογαριασμός</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Όνομα χρήστη" required desc="Χρησιμοποιείται για login.">
<input style={inputStyle} value={form.username} onChange={e => f('username', e.target.value)} />
</FormField>
<FormField label="Ρόλος" required desc="Καθορίζει τα δικαιώματα πρόσβασης.">
<select style={inputStyle} value={form.role} onChange={e => f('role', e.target.value)}>
<option value="waiter">Σερβιτόρος</option>
<option value="manager">Διαχειριστής</option>
</select>
</FormField>
</div>
</div>
</div>
{/* Column 2: Contact info */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
<div>
<SectionLabel>Επικοινωνία</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Κινητό τηλέφωνο" desc="Εσωτερική αναφορά μόνο.">
<input style={inputStyle} value={form.mobile_phone} onChange={e => f('mobile_phone', e.target.value)} />
</FormField>
<FormField label="Email" desc="Εσωτερική χρήση μόνο.">
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} />
</FormField>
<FormField label="Σημείωση" desc="Οποιαδήποτε εσωτερική σημείωση.">
<textarea style={{ ...inputStyle, resize: 'none' }} value={form.note} onChange={e => f('note', e.target.value)} rows={4} />
</FormField>
</div>
</div>
</div>
{/* Column 3: Info + PIN note */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
<div>
<SectionLabel>Πληροφορίες</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<InfoBlock label="Ημερομηνία δημιουργίας"
value={waiter.created_at ? new Date(waiter.created_at).toLocaleDateString('el-GR') : null} />
<InfoBlock label="Κατάσταση"
value={waiter.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
valueColor={waiter.is_active ? '#16a34a' : '#ef4444'} />
<InfoBlock label="Ρόλος"
value={waiter.role === 'waiter' ? 'Σερβιτόρος' : 'Διαχειριστής'} />
{waiter.role === 'waiter' && (
<InfoBlock label="Ζώνες" value={zoneLabel} />
)}
</div>
</div>
<div style={{ marginTop: 8, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>
Για αλλαγή PIN χρησιμοποιήστε την επιλογή <strong style={{ color: '#374151' }}>Επαναφορά PIN</strong> από το μενού Ενεργειών.
</p>
</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: '14px 26px', borderTop: '1px solid #f3f4f6', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 13.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
Ακύρωση
</button>
<button onClick={onSubmit} disabled={isPending || !canSubmit}
style={{ padding: '8px 20px', border: 'none', background: canSubmit ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed' }}>
{isPending ? 'Αποθήκευση…' : 'Αποθήκευση αλλαγών'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -44,7 +44,7 @@ export default function TablesPage() {
const [selected, setSelected] = useState(new Set())
const [anyHovered, setAnyHovered] = useState(false)
const dragGroupId = useRef(null)
const [dragOverId, setDragOverId] = useState(null)
const [dragOverGap, setDragOverGap] = useState(null) // index into groups array (0 = before first)
const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables-all', showInactive],
@@ -166,35 +166,56 @@ export default function TablesPage() {
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
{zoneTabs.map(tab => {
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
const isDragOver = dragOverId === tab.id
const groupIdx = isGroup ? groups.findIndex(g => g.id === tab.id) : -1
function handleDragOver(e) {
e.preventDefault()
// Use mouse position within the tab to decide left-half vs right-half gap
const rect = e.currentTarget.getBoundingClientRect()
const isLeftHalf = e.clientX < rect.left + rect.width / 2
setDragOverGap(isLeftHalf ? groupIdx : groupIdx + 1)
}
function handleDrop(e) {
e.preventDefault()
const fromId = dragGroupId.current
dragGroupId.current = null
setDragOverGap(null)
if (!fromId) return
const groupIds = groups.map(g => g.id)
const fromIdx = groupIds.indexOf(fromId)
if (fromIdx === -1 || dragOverGap === null) return
const reordered = [...groupIds]
reordered.splice(fromIdx, 1)
// After removing the dragged item, adjust target index
const insertAt = dragOverGap > fromIdx ? dragOverGap - 1 : dragOverGap
reordered.splice(insertAt, 0, fromId)
if (reordered.join() !== groupIds.join()) reorderGroups.mutate(reordered)
}
const showLineLeft = isGroup && dragOverGap === groupIdx
const showLineRight = isGroup && dragOverGap === groupIdx + 1 && groupIdx === groups.length - 1
return (
<div key={tab.id} className="relative flex items-stretch">
{/* Drop indicator line — left side of this tab */}
{showLineLeft && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 -translate-x-px" />
)}
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); clearSelect() }}
draggable={isGroup}
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
onDragOver={isGroup ? (e) => { e.preventDefault(); setDragOverId(tab.id) } : undefined}
onDragLeave={isGroup ? () => setDragOverId(null) : undefined}
onDrop={isGroup ? (e) => {
e.preventDefault()
setDragOverId(null)
const fromId = dragGroupId.current
if (!fromId || fromId === tab.id) return
const groupIds = groups.map(g => g.id)
const fromIdx = groupIds.indexOf(fromId)
const toIdx = groupIds.indexOf(tab.id)
if (fromIdx === -1 || toIdx === -1) return
const reordered = [...groupIds]
reordered.splice(fromIdx, 1)
reordered.splice(toIdx, 0, fromId)
reorderGroups.mutate(reordered)
} : undefined}
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverId(null) } : undefined}
onDragOver={isGroup ? handleDragOver : undefined}
onDragLeave={isGroup ? () => setDragOverGap(null) : undefined}
onDrop={isGroup ? handleDrop : undefined}
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverGap(null) } : undefined}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-sky-500 text-sky-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
} ${isDragOver ? 'bg-sky-50 border-sky-300' : ''} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
{isGroup && <span className="text-slate-300 text-xs mr-0.5 select-none"></span>}
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
@@ -203,6 +224,12 @@ export default function TablesPage() {
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
</span>
</button>
{/* Drop indicator line — right side of the last group tab */}
{showLineRight && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 translate-x-px" />
)}
</div>
)
})}
</div>

View File

@@ -289,7 +289,7 @@ function FlagPills({ flags, displayMode = 'both', onOverflowClick }) {
)
}
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', onClick, onQuickAction, onFlagsClick }) {
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', upcomingReservation = null, onClick, onQuickAction, onFlagsClick }) {
const s = COLORS[status] || COLORS.free
const [hover, setHover] = useState(false)
const [pressed, setPressed] = useState(false)
@@ -348,6 +348,15 @@ function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingP
display: 'inline-flex', alignItems: 'center', gap: 4,
}}></span>
)}
{upcomingReservation && (
<span title={`${upcomingReservation.guest_name} · ${upcomingReservation.party_size} άτομα · ${new Date(upcomingReservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}`} style={{
fontSize: 11, fontWeight: 700, background: '#4f46e5', color: '#e0e7ff',
borderRadius: 999, padding: '2px 8px', flexShrink: 0,
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
🔒 RESERVED · {new Date(upcomingReservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<FlagPills
flags={flags}
displayMode={flagDisplayMode}
@@ -741,6 +750,7 @@ export default function TablesPage() {
hasPendingPrint={hasPendingPrint}
flags={tableFlags}
flagDisplayMode={flagDisplayMode}
upcomingReservation={table.upcoming_reservation ?? null}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}

View File

@@ -9,7 +9,7 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtNum, fmtEUR, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
import { fmtNum, fmtEUR, fmtDate, fmtTime, 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) }
@@ -37,6 +37,7 @@ const PRINT_TYPES = [
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
const [printType, setPrintType] = useState('quick')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [itemBreakdown, setItemBreakdown] = useState(false)
const [printing, setPrinting] = useState(false)
const printerOptions = [
@@ -53,23 +54,25 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
}
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')
const body = {
printer_target_id: printerTargetId,
printer_id: parseInt(targetPrinter, 10),
mode: printType,
item_breakdown: itemBreakdown,
}
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from || (new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) + 'T00:00:00')
body.to_dt = 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,
})
await client.post('/api/reports/print/printer', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
@@ -80,105 +83,207 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=800,height=700')
const win = window.open('', '_blank', 'width=820,height=750')
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>
`
// ── Group logs by printer ──────────────────────────────────────────────
// For "all printers" we produce one section per printer.
// For a single selected printer the single section is the same format.
const printerMap = {}
for (const j of logs) {
const key = j.printer_name
if (!printerMap[key]) {
printerMap[key] = { name: key, printJobs: 0, orderIds: new Set(), totalItems: 0, totalValue: 0, orders: [] }
}
const g = printerMap[key]
g.printJobs += 1
g.orderIds.add(j.order_id)
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
g.totalItems += itemQty
if (j.order_total != null) g.totalValue += j.order_total
g.orders.push(j)
}
// De-dup order totals per printer group (same order_id → count once)
for (const g of Object.values(printerMap)) {
const seen = {}
g.totalValue = 0
for (const j of g.orders) {
if (j.success && j.order_total != null && !seen[j.order_id]) {
seen[j.order_id] = true
g.totalValue += j.order_total
}
}
}
const groups = Object.values(printerMap)
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('')}
`
// ── Period label ───────────────────────────────────────────────────────
let periodRow = ''
if (queryParams.business_day_id) {
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${stats.periodLabel}</span></div>`
} 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('')}
`
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>`
}
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${body}</body></html>`)
// ── Titles per mode ────────────────────────────────────────────────────
const titles = {
quick: 'Γρήγορη Ανάλυση Εκτυπώσεων',
orders: 'Ανάλυση Εκτυπώσεων με Παραγγελίες',
detailed: 'Αναλυτικό Ιστορικό Εκτυπώσεων',
}
// ── Render one printer block ───────────────────────────────────────────
function renderPrinterBlock(g) {
const orderCount = g.orderIds.size
const statsBlock = `
<div class="printer-header">
<span>Εκτυπωτής</span><span>${g.name}</span>
</div>
<div class="divider"></div>
<div class="row"><span>Εκτυπώσεις</span><span>${fmtNum(g.printJobs)}</span></div>
<div class="row"><span>Παραγγελίες</span><span>${fmtNum(orderCount)}</span></div>
<div class="row"><span>Είδη</span><span>${fmtNum(g.totalItems)}</span></div>
<div class="row value-row"><span>Αξία Ειδών</span><span>${fmtEUR(g.totalValue)}</span></div>
`
if (printType === 'quick') {
return `<div class="printer-block">${statsBlock}</div>`
}
if (printType === 'orders') {
// Unique orders only
const seenOrders = {}
const orderLines = g.orders
.filter(j => j.success)
.filter(j => { if (seenOrders[j.order_id]) return false; seenOrders[j.order_id] = true; return true })
.map(j => {
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
return `<div class="order-line">
<span class="order-id">#${j.order_id} · ${j.table || '—'}</span>
<span class="order-meta">${itemQty} είδη</span>
<span class="order-value">${j.order_total != null ? fmtEUR(j.order_total) : '—'}</span>
</div>`
}).join('')
return `<div class="printer-block">${statsBlock}<div class="divider"></div>${orderLines}</div>`
}
// detailed — full log, each entry is a print job
const jobBlocks = g.orders.map(j => {
const result = j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'
const itemRows = (j.items || []).map(i =>
`<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`
).join('')
return `
<div class="job-block ${!j.success ? 'failed' : ''}">
<div class="job-header">
<span>#${j.order_id} · ${j.table || '—'}</span>
<span>${fmtDateTime(j.printed_at)} · ${result}</span>
</div>
${itemRows}
${j.order_total != null ? `<div class="job-total"><span>Σύνολο Παραγγελίας</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
</div>`
}).join('')
return `<div class="printer-block">${statsBlock}<div class="divider"></div>${jobBlocks}</div>`
}
// ── Summary footer (multi-printer only) ───────────────────────────────
function renderSummary() {
if (groups.length <= 1) return ''
const totJobs = groups.reduce((s, g) => s + g.printJobs, 0)
const totOrders = groups.reduce((s, g) => s + g.orderIds.size, 0)
const totItems = groups.reduce((s, g) => s + g.totalItems, 0)
const totValue = groups.reduce((s, g) => s + g.totalValue, 0)
return `
<div class="summary-block">
<div class="summary-title">Σύνολο Όλων των Εκτυπωτών</div>
<div class="row"><span>Εκτυπώσεις</span><span>${fmtNum(totJobs)}</span></div>
<div class="row"><span>Παραγγελίες</span><span>${fmtNum(totOrders)}</span></div>
<div class="row"><span>Είδη</span><span>${fmtNum(totItems)}</span></div>
<div class="row value-row"><span>Αξία Ειδών</span><span>${fmtEUR(totValue)}</span></div>
</div>`
}
// ── Product breakdown (optional) ───────────────────────────────────────
function renderItemBreakdown() {
if (!itemBreakdown) return ''
// Merge item counts across all printer groups
const combined = {}
for (const g of groups) {
for (const j of g.orders) {
if (!j.success) continue
for (const i of (j.items || [])) {
if (!combined[i.name]) combined[i.name] = { qty: 0 }
combined[i.name].qty += i.quantity
}
}
}
if (!Object.keys(combined).length) return ''
const sorted = Object.entries(combined).sort((a, b) => b[1].qty - a[1].qty)
return `
<div class="breakdown-block">
<div class="breakdown-title">Ανάλυση Προϊόντων</div>
${sorted.map(([name, d]) => `
<div class="breakdown-row">
<span class="breakdown-name">${name}</span>
<span class="breakdown-qty">${d.qty}</span>
</div>`).join('')}
</div>`
}
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: 760px; }
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: 3px 0; }
.divider { border-top: 1px dashed #999; margin: 6px 0; }
.printer-block { border: 1px solid #ccc; border-radius: 4px; padding: 10px; margin-bottom: 14px; }
.printer-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 13px; margin-bottom: 4px; }
.value-row { font-weight: bold; }
.order-line { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; }
.order-id { flex: 1; }
.order-meta { color: #666; min-width: 60px; text-align: right; }
.order-value { min-width: 70px; text-align: right; font-weight: bold; }
.job-block { margin-bottom: 8px; padding: 6px 0; border-bottom: 1px dotted #ddd; }
.job-block:last-child { border-bottom: none; }
.job-block.failed { color: #b00; }
.job-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 11px; margin-bottom: 3px; }
.item-row { display: flex; justify-content: space-between; padding-left: 12px; font-size: 11px; color: #333; }
.job-total { display: flex; justify-content: space-between; padding-left: 12px; font-size: 11px; font-weight: bold; margin-top: 3px; }
.summary-block { border: 2px solid #000; border-radius: 4px; padding: 10px; margin-top: 8px; }
.summary-title { font-weight: bold; font-size: 13px; margin-bottom: 6px; text-align: center; letter-spacing: 0.04em; }
.breakdown-block { border: 1px solid #888; border-radius: 4px; padding: 10px; margin-top: 14px; }
.breakdown-title { font-weight: bold; font-size: 13px; margin-bottom: 8px; letter-spacing: 0.04em; }
.breakdown-row { display: flex; justify-content: space-between; padding: 2px 0; border-bottom: 1px dotted #ccc; font-size: 12px; }
.breakdown-row:last-child { border-bottom: none; }
.breakdown-name { flex: 1; }
.breakdown-qty { font-weight: bold; min-width: 40px; text-align: right; }
@media print { body { padding: 0; } }
</style>
`
const htmlBody = `
<h1>${titles[printType] || 'Αναφορά Εκτυπωτή'}</h1>
<div class="meta">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
${periodRow}
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
</div>
${groups.map(renderPrinterBlock).join('')}
${renderSummary()}
${renderItemBreakdown()}
`
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${htmlBody}</body></html>`)
win.document.close()
win.focus()
win.print()
@@ -221,6 +326,19 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
</div>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 transition hover:border-slate-300 hover:bg-slate-50">
<input
type="checkbox"
checked={itemBreakdown}
onChange={e => setItemBreakdown(e.target.checked)}
className="mt-0.5 accent-sky-500"
/>
<div>
<div className="font-medium text-slate-800">Ανάλυση Προϊόντων</div>
<div className="text-[12px] text-slate-500">Προσθέτει σύνολο τεμαχίων ανά προϊόν στο τέλος</div>
</div>
</label>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
@@ -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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -155,9 +155,144 @@ 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 (
<label className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}>
<div
className="absolute left-0 top-full mt-1 z-50 w-64 rounded-lg border border-slate-200 bg-white shadow-xl ring-1 ring-slate-100 select-none"
onMouseDown={e => e.preventDefault()}
onClick={e => e.stopPropagation()}
>
{/* Month navigation */}
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-100">
<button
type="button"
onClick={prevMonth}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span className="text-[13px] font-semibold text-slate-800">
{GR_MONTHS[viewMonth]} {viewYear}
</span>
<button
type="button"
onClick={nextMonth}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
{/* Day-of-week headers */}
<div className="grid grid-cols-7 px-2 pt-2 pb-1">
{GR_DAYS.map(d => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-400 py-0.5">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 px-2 pb-2 gap-y-0.5">
{cells.map((day, i) => {
if (!day) return <div key={`e${i}`} />
const isSelected = day === selectedD && viewMonth === selectedM && viewYear === selectedY
const isToday = day === todayD && viewMonth === todayM && viewYear === todayY
return (
<button
key={day}
type="button"
onClick={e => selectDay(e, day)}
className={`
h-8 w-8 mx-auto rounded-full text-[12px] font-medium transition-colors
${isSelected
? 'bg-sky-500 text-white'
: isToday
? 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 hover:bg-sky-100'
: 'text-slate-700 hover:bg-slate-100'
}
`}
>
{day}
</button>
)
})}
</div>
</div>
)
}
export function FilterDateInput({ value, onChange, label }) {
const [open, setOpen] = useState(false)
const containerRef = useRef(null)
useEffect(() => {
if (!open) return
function handler(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(o => !o)}
className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}
>
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
@@ -166,13 +301,22 @@ export function FilterDateInput({ value, onChange, label }) {
{label}
</span>
)}
<input
type="date"
<span className="text-[13px] font-normal whitespace-nowrap">
{value
? <span className="text-slate-700">{fmtGR(value)}</span>
: <span className="text-slate-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>
}
</span>
</button>
{open && (
<CalendarDropdown
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-transparent outline-none text-[13px] text-slate-700 font-normal"
onCommit={onChange}
onClose={() => setOpen(false)}
/>
</label>
)}
</div>
)
}

View File

@@ -12,7 +12,7 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime } from '../shared/reportDesignTokens'
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() {
@@ -103,7 +103,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<div className="flex-1 overflow-y-auto p-6">
<Panel
title="Όλες οι Βάρδιες"
subtitle={`${shifts.length} βάρδιες · ${from}${to}`}
subtitle={`${shifts.length} βάρδιες · ${fmtDateStr(from)}${fmtDateStr(to)}`}
padded={false}
>
{shifts.length === 0 ? (

View File

@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, avatarColor } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, avatarColor, 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) }
@@ -68,7 +68,7 @@ export default function StaffLeaderboard() {
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Κορυφαίοι" subtitle={`${from}${to}`}>
<Panel title="Κορυφαίοι" subtitle={`${fmtDateStr(from)}${fmtDateStr(to)}`}>
{top3.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
) : (

View File

@@ -1,71 +1,72 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Mirrors waiter_pwa/src/store/tableColourStore.js — same localStorage key so both apps share state.
// Must stay in sync with waiter_pwa/src/store/tableColourStore.js DEFAULT_COLOURS.
// The PWA uses these as its hardcoded fallback; the manager uses them for "reset to defaults".
export const DEFAULT_COLOURS = {
light: {
free: {
cardBg: '#d6d6d6',
badgeBg: '#e3e3e3',
nameText: '#3b485e',
badgeText: '#adadad',
cardBg: '#dde5ef',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#3d5270',
badgeText: '#3d5270',
},
mine: {
cardBg: '#e83030',
badgeBg: 'rgba(255,255,255,0.40)',
cardBg: '#e8610a',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#e8610a',
},
open: {
cardBg: '#ffbb29',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FF8F60',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#FF8F60',
},
partially_paid: {
cardBg: '#e89230',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FFDC67',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#d4a800',
},
paid: {
cardBg: '#79ad38',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#81D264',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#81D264',
},
},
dark: {
free: {
cardBg: '#243044',
badgeBg: 'rgba(26,35,50,0.50)',
nameText: '#ffffff',
badgeText: '#adadad',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#94b8d4',
badgeText: '#94b8d4',
},
mine: {
cardBg: '#e83030',
badgeBg: 'rgba(255,255,255,0.40)',
cardBg: '#e8610a',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#e8610a',
},
open: {
cardBg: '#ffbb29',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FF8F60',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#FF8F60',
},
partially_paid: {
cardBg: '#e89230',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FFDC67',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#d4a800',
},
paid: {
cardBg: '#79ad38',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#81D264',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#81D264',
},
},
}

View File

@@ -94,7 +94,21 @@ export const fmtTime = (iso) => {
}
export const fmtDate = (iso) => {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
// Parse as local time by replacing the T separator so Date treats it as local
const d = new Date(String(iso).replace('T', ' '))
if (isNaN(d)) return String(iso)
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
// Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion
export const fmtDateStr = (yyyymmdd) => {
if (!yyyymmdd) return '—'
const [y, m, d] = String(yyyymmdd).split('-')
if (!y || !m || !d) return yyyymmdd
return `${d}/${m}/${y}`
}
export const fmtDateTime = (iso) => {
if (!iso) return '—'

View File

@@ -636,6 +636,11 @@ export default function TableCard({
'4x3': Card4x3,
}[density] || Card2x2
const reservation = table.upcoming_reservation ?? null
const reservedTime = reservation
? new Date(reservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
: null
return (
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
<button
@@ -651,6 +656,21 @@ export default function TableCard({
>
<CardComponent {...cardProps} />
</button>
{reservation && (
<div style={{
position: 'absolute', top: 6, left: 6, zIndex: 10,
pointerEvents: 'none',
}}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
background: '#4f46e5', color: '#e0e7ff',
fontSize: 9, fontWeight: 800, letterSpacing: 0.3,
borderRadius: 4, padding: '2px 6px',
}}>
🔒 {reservedTime}
</span>
</div>
)}
{showTip && flags.length > 0 && (
<div style={{