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:
659
manager_dashboard/src/components/WorkdaySummaryModal.jsx
Normal file
659
manager_dashboard/src/components/WorkdaySummaryModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import Badge from '../ui/Badge'
|
|||||||
import { ConfirmModal } from '../ui/Modal'
|
import { ConfirmModal } from '../ui/Modal'
|
||||||
import { LicenseContext } from '../layouts/AppLayout'
|
import { LicenseContext } from '../layouts/AppLayout'
|
||||||
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
|
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
|
||||||
|
import WorkdaySummaryModal from '../components/WorkdaySummaryModal'
|
||||||
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -259,7 +260,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 720,
|
background: 'white', borderRadius: 20, width: '100%', maxWidth: 800,
|
||||||
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -387,17 +388,17 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
{order && (
|
{order && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '14px 22px', borderTop: '1px solid #edeff1',
|
padding: '12px 22px', borderTop: '1px solid #edeff1',
|
||||||
display: 'flex', gap: 8, flexWrap: 'nowrap', flexShrink: 0,
|
display: 'flex', gap: 8, flexWrap: 'wrap', flexShrink: 0,
|
||||||
background: '#fafafa', alignItems: 'center',
|
background: '#fafafa', alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
{isOpen && activeItems.length > 0 && (
|
{isOpen && activeItems.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 16px', borderRadius: 8,
|
minHeight: 36, padding: '6px 16px', borderRadius: 8,
|
||||||
background: '#3758c9', border: 'none', color: 'white',
|
background: '#3758c9', border: 'none', color: 'white',
|
||||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
fontSize: 13, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>Πληρωμή όλων</button>
|
>Πληρωμή όλων</button>
|
||||||
)}
|
)}
|
||||||
@@ -406,23 +407,23 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #fecaca', background: '#fef2f2',
|
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>
|
>Ακύρωση</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{printers.length > 0 && (
|
{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
|
<select
|
||||||
value={printerId}
|
value={printerId}
|
||||||
onChange={e => setPrinterId(e.target.value)}
|
onChange={e => setPrinterId(e.target.value)}
|
||||||
@@ -439,9 +440,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
|
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
|
||||||
disabled={!printerId}
|
disabled={!printerId}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
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,
|
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
>🖨 Εκτύπωση</button>
|
>🖨 Εκτύπωση</button>
|
||||||
@@ -450,9 +451,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenFull}
|
onClick={onOpenFull}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
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',
|
marginLeft: printers.length > 0 ? 0 : 'auto',
|
||||||
}}
|
}}
|
||||||
>Πλήρης εικόνα →</button>
|
>Πλήρης εικόνα →</button>
|
||||||
@@ -792,32 +793,412 @@ 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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', border: '1px solid #edeff1',
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
borderRadius: 16, overflow: 'hidden',
|
padding: '10px 16px',
|
||||||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
borderBottom: '1px solid #f3f4f6',
|
||||||
}}>
|
}}>
|
||||||
|
{/* Time block */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
minWidth: 44, textAlign: 'center', flexShrink: 0,
|
||||||
padding: '16px 20px', borderBottom: '1px solid #edeff1',
|
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Κρατήσεις σήμερα</div>
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', lineHeight: 1 }}>{time}</div>
|
||||||
<button style={{
|
<div style={{ fontSize: 10, color: '#8a9099', marginTop: 2 }}>{dateStr}</div>
|
||||||
height: 28, padding: '0 12px', borderRadius: 8,
|
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
|
||||||
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
|
||||||
}}>+ Νέα</button>
|
|
||||||
</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={{
|
<div style={{
|
||||||
padding: '32px 20px', textAlign: 'center',
|
background: 'white', border: '1px solid #edeff1',
|
||||||
color: '#b8bdc4', fontSize: 13,
|
borderRadius: 16, overflow: 'hidden',
|
||||||
|
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 28, marginBottom: 8 }}>📅</div>
|
<div style={{
|
||||||
Σύντομα — σύστημα κρατήσεων υπό ανάπτυξη
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px', borderBottom: '1px solid #edeff1',
|
||||||
|
}}>
|
||||||
|
<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: '#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={{
|
||||||
|
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
maxHeight: '90vh', overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1133,8 +1514,6 @@ function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onV
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [showStartShift, setShowStartShift] = useState(false)
|
const [showStartShift, setShowStartShift] = useState(false)
|
||||||
const [closeDetails, setCloseDetails] = useState(null)
|
|
||||||
const [forceClosing, setForceClosing] = useState(false)
|
|
||||||
const [retryingId, setRetryingId] = useState(null)
|
const [retryingId, setRetryingId] = useState(null)
|
||||||
const [quickView, setQuickView] = useState(null)
|
const [quickView, setQuickView] = useState(null)
|
||||||
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
|
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
|
||||||
@@ -1145,6 +1524,9 @@ export default function DashboardPage() {
|
|||||||
// Quick message to single waiter
|
// Quick message to single waiter
|
||||||
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
|
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
|
||||||
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
|
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 navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const license = useContext(LicenseContext)
|
const license = useContext(LicenseContext)
|
||||||
@@ -1211,26 +1593,6 @@ export default function DashboardPage() {
|
|||||||
openDayMut.mutate()
|
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) {
|
async function handleEndShiftConfirm(notes) {
|
||||||
if (!endShiftTarget) return
|
if (!endShiftTarget) return
|
||||||
setEndShiftBusy(true)
|
setEndShiftBusy(true)
|
||||||
@@ -1375,11 +1737,18 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<button onClick={() => handleCloseDay(false)} style={{
|
<>
|
||||||
height: 38, padding: '0 18px', borderRadius: 10,
|
<button onClick={() => setWorkdaySummaryMode('view')} style={{
|
||||||
background: '#dc2626', border: 'none', color: 'white',
|
height: 38, padding: '0 14px', borderRadius: 10,
|
||||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
background: 'white', border: '1px solid #dfe2e6', color: '#374151',
|
||||||
}}>Κλείσιμο Ημέρας</button>
|
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={{
|
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
|
||||||
height: 38, padding: '0 18px', borderRadius: 10,
|
height: 38, padding: '0 18px', borderRadius: 10,
|
||||||
@@ -1515,7 +1884,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* ── Right column — Reservations + Messages ───────────────────────── */}
|
{/* ── Right column — Reservations + Messages ───────────────────────── */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
<ReservationsCard />
|
<ReservationsCard tables={tables} onNew={() => setShowNewReservation(true)} />
|
||||||
<MessagesCard waiters={allWaiters} tables={tables} />
|
<MessagesCard waiters={allWaiters} tables={tables} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1555,12 +1924,31 @@ export default function DashboardPage() {
|
|||||||
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
|
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{closeDetails && (
|
{workdaySummaryMode && (
|
||||||
<CloseConfirmModal
|
<WorkdaySummaryModal
|
||||||
details={closeDetails}
|
showCloseAction={workdaySummaryMode === 'close'}
|
||||||
onClose={() => setCloseDetails(null)}
|
onClose={() => setWorkdaySummaryMode(null)}
|
||||||
onConfirm={() => handleCloseDay(true)}
|
onDayClosed={() => {
|
||||||
busy={forceClosing}
|
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 && (
|
{quickView && (
|
||||||
|
|||||||
@@ -636,7 +636,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk bar ──────────────────────────────────────────────────────────────────
|
// ── Bulk bar ──────────────────────────────────────────────────────────────────
|
||||||
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
|
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
|
||||||
return (
|
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', 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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -645,7 +645,7 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
|
|||||||
</button>
|
</button>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{count} επιλεγμένα</span>
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{count} επιλεγμένα</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Διαθέσιμα', icon: 'eye', action: onAvail },
|
{ label: 'Διαθέσιμα', icon: 'eye', action: onAvail },
|
||||||
{ label: 'Μη διαθέσιμα', icon: 'eyeOff', action: onUnavail },
|
{ label: 'Μη διαθέσιμα', icon: 'eyeOff', action: onUnavail },
|
||||||
@@ -659,6 +659,111 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -938,6 +1043,8 @@ export default function ProductsTab() {
|
|||||||
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
|
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
|
||||||
// local override of group display order (array of group keys), reset when scope changes
|
// local override of group display order (array of group keys), reset when scope changes
|
||||||
const [localGroupOrder, setLocalGroupOrder] = useState(null)
|
const [localGroupOrder, setLocalGroupOrder] = useState(null)
|
||||||
|
const [bulkPrinterZoneModal, setBulkPrinterZoneModal] = useState(false)
|
||||||
|
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
|
||||||
|
|
||||||
// drag state for group headers
|
// drag state for group headers
|
||||||
const groupDragId = useRef(null)
|
const groupDragId = useRef(null)
|
||||||
@@ -1163,6 +1270,26 @@ export default function ProductsTab() {
|
|||||||
setConfirmDelete(null); invalidate(); clearSelect()
|
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() {
|
function handleConfirmDelete() {
|
||||||
if (!confirmDelete) return
|
if (!confirmDelete) return
|
||||||
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
|
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
|
||||||
@@ -1345,6 +1472,8 @@ export default function ProductsTab() {
|
|||||||
onAvail={() => bulkAction('available')}
|
onAvail={() => bulkAction('available')}
|
||||||
onUnavail={() => bulkAction('unavailable')}
|
onUnavail={() => bulkAction('unavailable')}
|
||||||
onArchive={() => bulkAction('archive')}
|
onArchive={() => bulkAction('archive')}
|
||||||
|
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
|
||||||
|
onMoveToCategory={() => setBulkMoveCatModal(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1500,6 +1629,26 @@ export default function ProductsTab() {
|
|||||||
onCancel={() => setConfirmDelete(null)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<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)}`}
|
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
@@ -30,6 +30,13 @@ const DIVIDER_OPTIONS = [
|
|||||||
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
{ 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 = {
|
const FONT_DEFAULTS = {
|
||||||
'print.font_order_number': '48:1:0',
|
'print.font_order_number': '48:1:0',
|
||||||
'print.font_meta': '0:0:0',
|
'print.font_meta': '0:0:0',
|
||||||
@@ -41,6 +48,7 @@ const FONT_DEFAULTS = {
|
|||||||
'print.font_item_note': '0:0:0',
|
'print.font_item_note': '0:0:0',
|
||||||
'print.font_order_note': '0:1:0',
|
'print.font_order_note': '0:1:0',
|
||||||
'print.divider_style': 'dash',
|
'print.divider_style': 'dash',
|
||||||
|
'print.dot_style': 'dot_space:0:0',
|
||||||
'print.ticket_mode': 'detailed',
|
'print.ticket_mode': 'detailed',
|
||||||
'print.beep_on_ticket': 'true',
|
'print.beep_on_ticket': 'true',
|
||||||
'print.beep_pattern': 'double',
|
'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 ────────────────────────────────────────────────────
|
// ── Ticket mode section ────────────────────────────────────────────────────
|
||||||
function TicketModeSection({ value, onChange, isPending, printers }) {
|
function TicketModeSection({ value, onChange, isPending, printers }) {
|
||||||
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
||||||
@@ -535,7 +637,7 @@ function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }
|
|||||||
// ── Printers section ───────────────────────────────────────────────────────
|
// ── Printers section ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
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 }) {
|
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||||
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
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>)}
|
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
||||||
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
||||||
style={btnPrimary}>Αποθήκευση</button>
|
style={btnPrimary}>Αποθήκευση</button>
|
||||||
@@ -631,6 +738,7 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
|||||||
{printer.ip_address}:{printer.port}
|
{printer.ip_address}:{printer.port}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</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>
|
</div>
|
||||||
|
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -844,15 +952,23 @@ export default function PrintFontsTab() {
|
|||||||
{FONT_GROUPS.map(group => (
|
{FONT_GROUPS.map(group => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
<SubgroupHeader label={group.group} />
|
<SubgroupHeader label={group.group} />
|
||||||
{group.fields.map((field, idx) => (
|
{group.fields.map((field) => (
|
||||||
<FontRow
|
<React.Fragment key={field.key}>
|
||||||
key={field.key}
|
<FontRow
|
||||||
field={field}
|
field={field}
|
||||||
value={val(field.key)}
|
value={val(field.key)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isPending={updateMut.isPending}
|
isPending={updateMut.isPending}
|
||||||
nested={group.fields.length > 1}
|
nested={group.fields.length > 1}
|
||||||
/>
|
/>
|
||||||
|
{field.key === 'print.font_ingredient' && (
|
||||||
|
<DotStyleRow
|
||||||
|
value={val('print.dot_style')}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
@@ -46,15 +46,30 @@ function PinInput({ value, onChange }) {
|
|||||||
onChange(value + d)
|
onChange(value + d)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
|
||||||
<div className="flex justify-center gap-2">
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{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>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, width: '100%' }}>
|
||||||
{DIGITS.map((d, i) => (
|
{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}
|
{d}
|
||||||
</button>
|
</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 }) {
|
function ZoneModal({ waiter, groups, onClose }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
// Derive initial state from waiter's zone_assignments
|
|
||||||
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
|
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 assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
|
||||||
|
|
||||||
const [allZones, setAllZones] = useState(hasAllZones)
|
const [allZones, setAllZones] = useState(hasAllZones)
|
||||||
const [selected, setSelected] = useState(new Set(assignedIds))
|
const [selected, setSelected] = useState(new Set(assignedIds))
|
||||||
|
|
||||||
@@ -79,91 +191,93 @@ function ZoneModal({ waiter, groups, onClose }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function toggleGroup(gid) {
|
function toggleGroup(gid) {
|
||||||
setSelected(prev => {
|
setSelected(prev => { const next = new Set(prev); next.has(gid) ? next.delete(gid) : next.add(gid); return next })
|
||||||
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] })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<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">
|
<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>
|
<h2 className="font-bold text-gray-800">Ζώνες — {waiter.username}</h2>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
<input
|
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={allZones}
|
||||||
type="checkbox"
|
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }} />
|
||||||
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>
|
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{!allZones && (
|
{!allZones && (
|
||||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && <p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>}
|
||||||
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
|
|
||||||
)}
|
|
||||||
{groups.map(g => (
|
{groups.map(g => (
|
||||||
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
||||||
<input
|
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={selected.has(g.id)} onChange={() => toggleGroup(g.id)} />
|
||||||
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>
|
<span className="text-gray-700">{g.name}</span>
|
||||||
{g.color && (
|
{g.color && <span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />}
|
||||||
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!allZones && selected.size === 0 && (
|
{!allZones && selected.size === 0 && (
|
||||||
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
|
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.</p>
|
||||||
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
|
<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>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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() {
|
export default function WaitersPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [addModal, setAddModal] = useState(false)
|
const [addModal, setAddModal] = useState(false)
|
||||||
const [pinModal, setPinModal] = useState(null) // waiter id
|
const [pinModal, setPinModal] = useState(null)
|
||||||
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
const [zoneModal, setZoneModal] = useState(null)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||||
const [newPin, setNewPin] = useState('')
|
const [newPin, setNewPin] = useState('')
|
||||||
const [newForm, setNewForm] = useState(EMPTY_FORM)
|
const [newForm, setNewForm] = useState(EMPTY_FORM)
|
||||||
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
||||||
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
||||||
|
const [editModal, setEditModal] = useState(null)
|
||||||
const [editModal, setEditModal] = useState(null) // waiter object
|
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter' })
|
||||||
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter' })
|
|
||||||
const avatarInputRef = useRef(null)
|
const avatarInputRef = useRef(null)
|
||||||
const newAvatarInputRef = useRef(null)
|
const newAvatarInputRef = useRef(null)
|
||||||
|
|
||||||
@@ -171,7 +285,6 @@ export default function WaitersPage() {
|
|||||||
queryKey: ['waiters'],
|
queryKey: ['waiters'],
|
||||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: groups = [] } = useQuery({
|
const { data: groups = [] } = useQuery({
|
||||||
queryKey: ['table-groups'],
|
queryKey: ['table-groups'],
|
||||||
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
||||||
@@ -191,10 +304,7 @@ export default function WaitersPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Σερβιτόρος δημιουργήθηκε')
|
toast.success('Σερβιτόρος δημιουργήθηκε')
|
||||||
setAddModal(false)
|
setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null)
|
||||||
setNewForm(EMPTY_FORM)
|
|
||||||
setNewAvatarFile(null)
|
|
||||||
setNewAvatarPreview(null)
|
|
||||||
invalidate()
|
invalidate()
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||||
@@ -208,31 +318,22 @@ export default function WaitersPage() {
|
|||||||
|
|
||||||
const uploadAvatar = useMutation({
|
const uploadAvatar = useMutation({
|
||||||
mutationFn: ({ id, file }) => {
|
mutationFn: ({ id, file }) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData(); fd.append('file', file)
|
||||||
fd.append('file', file)
|
|
||||||
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
},
|
},
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => { toast.success('Avatar ανέβηκε'); setEditModal(res.data); invalidate() },
|
||||||
toast.success('Avatar ανέβηκε')
|
|
||||||
setEditModal(res.data)
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteAvatar = useMutation({
|
const deleteAvatar = useMutation({
|
||||||
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => { toast.success('Avatar αφαιρέθηκε'); setEditModal(res.data); invalidate() },
|
||||||
toast.success('Avatar αφαιρέθηκε')
|
|
||||||
setEditModal(res.data)
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleBlock = useMutation({
|
const toggleBlock = useMutation({
|
||||||
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||||||
onSuccess: () => { invalidate() },
|
onSuccess: () => invalidate(),
|
||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,248 +351,412 @@ export default function WaitersPage() {
|
|||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<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 }}>
|
<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>
|
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέος σερβιτόρος</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
|
||||||
<div className="card divide-y divide-gray-100">
|
<div className="card divide-y divide-gray-100">
|
||||||
{waiters.length === 0 && (
|
{waiters.length === 0 && (
|
||||||
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν σερβιτόροι.</p>
|
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν σερβιτόροι.</p>
|
||||||
)}
|
)}
|
||||||
{waiters.map(w => (
|
{waiters.map(w => (
|
||||||
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||||||
<WaiterAvatar waiter={w} size={44} />
|
<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>
|
|
||||||
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
|
|
||||||
{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} ζών${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'}`}>
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add waiter modal */}
|
<div className="flex-1 min-w-0">
|
||||||
{addModal && (
|
<div className="flex items-baseline gap-2">
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
|
||||||
<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>
|
||||||
)}
|
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
|
||||||
<div className="flex flex-col gap-2">
|
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
|
||||||
<input
|
{w.role === 'waiter' && (
|
||||||
ref={newAvatarInputRef}
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
type="file"
|
{w.zone_assignments.length === 0 ? 'Χωρίς ζώνες'
|
||||||
accept="image/*"
|
: w.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
|
||||||
className="hidden"
|
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
|
||||||
onChange={e => {
|
</p>
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
setNewAvatarFile(file)
|
|
||||||
setNewAvatarPreview(URL.createObjectURL(file))
|
|
||||||
}
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
{/* Active status badge — only this stays inline */}
|
||||||
<label className="label">Πλήρες όνομα *</label>
|
<span style={{
|
||||||
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit profile modal */}
|
{/* Add modal */}
|
||||||
{editModal && (
|
{addModal && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<AddWaiterModal
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
form={newForm} setForm={setNewForm}
|
||||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.full_name || editModal.username}</h2>
|
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,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Avatar section */}
|
{/* Edit modal */}
|
||||||
<div className="flex items-center gap-4">
|
{editModal && (
|
||||||
<WaiterAvatar waiter={editModal} size={64} />
|
<EditWaiterModal
|
||||||
<div className="flex flex-col gap-2">
|
waiter={editModal} form={editForm} setForm={setEditForm}
|
||||||
<input
|
avatarInputRef={avatarInputRef}
|
||||||
ref={avatarInputRef}
|
isPending={updateWaiter.isPending}
|
||||||
type="file"
|
isUploadPending={uploadAvatar.isPending}
|
||||||
accept="image/*"
|
isDeletePending={deleteAvatar.isPending}
|
||||||
className="hidden"
|
onClose={() => setEditModal(null)}
|
||||||
onChange={e => {
|
onSubmit={() => updateWaiter.mutate({
|
||||||
const file = e.target.files?.[0]
|
id: editModal.id,
|
||||||
if (file) uploadAvatar.mutate({ id: editModal.id, file })
|
username: editForm.username.trim() || undefined,
|
||||||
e.target.value = ''
|
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,
|
||||||
<button
|
})}
|
||||||
onClick={() => avatarInputRef.current?.click()}
|
onUploadAvatar={file => uploadAvatar.mutate({ id: editModal.id, file })}
|
||||||
disabled={uploadAvatar.isPending}
|
onDeleteAvatar={() => deleteAvatar.mutate(editModal.id)}
|
||||||
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
|
/>
|
||||||
>
|
)}
|
||||||
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
|
||||||
|
{/* 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">Επαναφορά 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>
|
</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>
|
</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>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reset PIN modal */}
|
{confirmDelete !== null && (
|
||||||
{pinModal !== null && (
|
<ConfirmModal
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
title="Διαγραφή σερβιτόρου;"
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
|
message="Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
||||||
<h2 className="font-bold text-gray-800">Reset PIN — {waiters.find(w => w.id === pinModal)?.username}</h2>
|
confirmLabel="Διαγραφή"
|
||||||
<PinInput value={newPin} onChange={setNewPin} />
|
confirmVariant="danger"
|
||||||
<div className="flex gap-3 pt-2">
|
onConfirm={() => deleteWaiter.mutate(confirmDelete)}
|
||||||
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
onCancel={() => setConfirmDelete(null)}
|
||||||
<button
|
/>
|
||||||
onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })}
|
)}
|
||||||
disabled={newPin.length < 4}
|
|
||||||
className="flex-1 btn btn-primary"
|
|
||||||
>
|
|
||||||
Αποθήκευση
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{confirmDelete !== null && (
|
{zoneModal && (
|
||||||
<ConfirmModal
|
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
|
||||||
title="Διαγραφή σερβιτόρου;"
|
)}
|
||||||
message="Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
</div>
|
||||||
confirmLabel="Διαγραφή"
|
</div>
|
||||||
confirmVariant="danger"
|
)
|
||||||
onConfirm={() => deleteWaiter.mutate(confirmDelete)}
|
}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
|
||||||
/>
|
// ── 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 }))
|
||||||
{zoneModal && (
|
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim() && form.pin.length >= 4
|
||||||
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
|
|
||||||
)}
|
return (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function TablesPage() {
|
|||||||
const [selected, setSelected] = useState(new Set())
|
const [selected, setSelected] = useState(new Set())
|
||||||
const [anyHovered, setAnyHovered] = useState(false)
|
const [anyHovered, setAnyHovered] = useState(false)
|
||||||
const dragGroupId = useRef(null)
|
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({
|
const { data: tables = [], isLoading } = useQuery({
|
||||||
queryKey: ['tables-all', showInactive],
|
queryKey: ['tables-all', showInactive],
|
||||||
@@ -166,43 +166,70 @@ export default function TablesPage() {
|
|||||||
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
||||||
{zoneTabs.map(tab => {
|
{zoneTabs.map(tab => {
|
||||||
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
|
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 (
|
return (
|
||||||
<button
|
<div key={tab.id} className="relative flex items-stretch">
|
||||||
key={tab.id}
|
{/* Drop indicator line — left side of this tab */}
|
||||||
onClick={() => { setActiveTab(tab.id); clearSelect() }}
|
{showLineLeft && (
|
||||||
draggable={isGroup}
|
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 -translate-x-px" />
|
||||||
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
|
)}
|
||||||
onDragOver={isGroup ? (e) => { e.preventDefault(); setDragOverId(tab.id) } : undefined}
|
|
||||||
onDragLeave={isGroup ? () => setDragOverId(null) : undefined}
|
<button
|
||||||
onDrop={isGroup ? (e) => {
|
onClick={() => { setActiveTab(tab.id); clearSelect() }}
|
||||||
e.preventDefault()
|
draggable={isGroup}
|
||||||
setDragOverId(null)
|
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
|
||||||
const fromId = dragGroupId.current
|
onDragOver={isGroup ? handleDragOver : undefined}
|
||||||
if (!fromId || fromId === tab.id) return
|
onDragLeave={isGroup ? () => setDragOverGap(null) : undefined}
|
||||||
const groupIds = groups.map(g => g.id)
|
onDrop={isGroup ? handleDrop : undefined}
|
||||||
const fromIdx = groupIds.indexOf(fromId)
|
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverGap(null) } : undefined}
|
||||||
const toIdx = groupIds.indexOf(tab.id)
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
if (fromIdx === -1 || toIdx === -1) return
|
activeTab === tab.id
|
||||||
const reordered = [...groupIds]
|
? 'border-sky-500 text-sky-600'
|
||||||
reordered.splice(fromIdx, 1)
|
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||||
reordered.splice(toIdx, 0, fromId)
|
} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
reorderGroups.mutate(reordered)
|
>
|
||||||
} : undefined}
|
{isGroup && <span className="text-slate-300 text-xs mr-0.5 select-none">⠿</span>}
|
||||||
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverId(null) } : undefined}
|
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
{tab.label}
|
||||||
activeTab === tab.id
|
<span className="ml-0.5 text-xs text-slate-400">
|
||||||
? 'border-sky-500 text-sky-600'
|
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
</span>
|
||||||
} ${isDragOver ? 'bg-sky-50 border-sky-300' : ''} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
</button>
|
||||||
>
|
|
||||||
{isGroup && <span className="text-slate-300 text-xs mr-0.5 select-none">⠿</span>}
|
{/* Drop indicator line — right side of the last group tab */}
|
||||||
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
{showLineRight && (
|
||||||
{tab.label}
|
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 translate-x-px" />
|
||||||
<span className="ml-0.5 text-xs text-slate-400">
|
)}
|
||||||
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
</div>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 s = COLORS[status] || COLORS.free
|
||||||
const [hover, setHover] = useState(false)
|
const [hover, setHover] = useState(false)
|
||||||
const [pressed, setPressed] = 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,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
}}>⏳</span>
|
}}>⏳</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
|
<FlagPills
|
||||||
flags={flags}
|
flags={flags}
|
||||||
displayMode={flagDisplayMode}
|
displayMode={flagDisplayMode}
|
||||||
@@ -741,6 +750,7 @@ export default function TablesPage() {
|
|||||||
hasPendingPrint={hasPendingPrint}
|
hasPendingPrint={hasPendingPrint}
|
||||||
flags={tableFlags}
|
flags={tableFlags}
|
||||||
flagDisplayMode={flagDisplayMode}
|
flagDisplayMode={flagDisplayMode}
|
||||||
|
upcomingReservation={table.upcoming_reservation ?? null}
|
||||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||||
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
|
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
|
||||||
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}
|
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import StatCard from '../shared/StatCard'
|
|||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { fmtNum, 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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -37,6 +37,7 @@ const PRINT_TYPES = [
|
|||||||
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
|
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
|
||||||
const [printType, setPrintType] = useState('quick')
|
const [printType, setPrintType] = useState('quick')
|
||||||
const [targetPrinter, setTargetPrinter] = useState('browser')
|
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||||
|
const [itemBreakdown, setItemBreakdown] = useState(false)
|
||||||
const [printing, setPrinting] = useState(false)
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
const printerOptions = [
|
const printerOptions = [
|
||||||
@@ -53,23 +54,25 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleThermalPrint() {
|
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 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 body = {
|
||||||
const toDt = queryParams.to || (new Date().toISOString().slice(0, 10) + 'T23:59:59')
|
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)
|
setPrinting(true)
|
||||||
try {
|
try {
|
||||||
await client.post('/api/reports/print/printer', {
|
await client.post('/api/reports/print/printer', body)
|
||||||
printer_target_id: printerTargetId,
|
|
||||||
printer_id: parseInt(targetPrinter, 10),
|
|
||||||
mode,
|
|
||||||
from_dt: fromDt,
|
|
||||||
to_dt: toDt,
|
|
||||||
})
|
|
||||||
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -80,105 +83,207 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleBrowserPrint() {
|
function handleBrowserPrint() {
|
||||||
const win = window.open('', '_blank', 'width=800,height=700')
|
const win = window.open('', '_blank', 'width=820,height=750')
|
||||||
if (!win) { onClose(); return }
|
if (!win) { onClose(); return }
|
||||||
|
|
||||||
const styles = `
|
// ── Group logs by printer ──────────────────────────────────────────────
|
||||||
<style>
|
// For "all printers" we produce one section per printer.
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
// For a single selected printer the single section is the same format.
|
||||||
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; }
|
const printerMap = {}
|
||||||
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
|
for (const j of logs) {
|
||||||
h2 { font-size: 13px; font-weight: bold; margin: 14px 0 6px; }
|
const key = j.printer_name
|
||||||
.row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px dotted #ccc; }
|
if (!printerMap[key]) {
|
||||||
.row:last-child { border-bottom: none; }
|
printerMap[key] = { name: key, printJobs: 0, orderIds: new Set(), totalItems: 0, totalValue: 0, orders: [] }
|
||||||
.section { margin-bottom: 16px; }
|
}
|
||||||
.order-block { border: 1px solid #ccc; border-radius: 4px; padding: 8px; margin-bottom: 8px; }
|
const g = printerMap[key]
|
||||||
.order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 4px; }
|
g.printJobs += 1
|
||||||
.item-row { padding-left: 12px; display: flex; justify-content: space-between; font-size: 11px; color: #333; }
|
g.orderIds.add(j.order_id)
|
||||||
.failed { color: #c00; }
|
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
|
||||||
.top-items li { padding: 2px 0; }
|
g.totalItems += itemQty
|
||||||
@media print { body { padding: 0; } }
|
if (j.order_total != null) g.totalValue += j.order_total
|
||||||
</style>
|
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'
|
const filterLabel = printerF === 'all'
|
||||||
? 'Όλοι οι Εκτυπωτές'
|
? 'Όλοι οι Εκτυπωτές'
|
||||||
: (printers.find(p => String(p.id) === printerF)?.name || printerF)
|
: (printers.find(p => String(p.id) === printerF)?.name || printerF)
|
||||||
|
|
||||||
let body = ''
|
// ── Period label ───────────────────────────────────────────────────────
|
||||||
|
let periodRow = ''
|
||||||
if (printType === 'quick') {
|
if (queryParams.business_day_id) {
|
||||||
const topItems = Object.entries(stats.itemCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${stats.periodLabel}</span></div>`
|
||||||
const topWaiters = Object.entries(stats.waiterCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
||||||
const avgItems = stats.total > 0 ? (stats.totalItemQty / stats.total).toFixed(1) : '—'
|
|
||||||
|
|
||||||
body = `
|
|
||||||
<h1>Σύνοψη Εκτυπωτή</h1>
|
|
||||||
<div class="section">
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
|
||||||
<div class="row"><span>Περίοδος</span><span>${stats.periodLabel}</span></div>
|
|
||||||
</div>
|
|
||||||
<h2>Στατιστικά</h2>
|
|
||||||
<div class="section">
|
|
||||||
<div class="row"><span>Συνολικές Εκτυπώσεις</span><span>${fmtNum(stats.total)}</span></div>
|
|
||||||
<div class="row"><span>Αποτυχημένες Εκτυπώσεις</span><span class="${stats.failed > 0 ? 'failed' : ''}">${fmtNum(stats.failed)}</span></div>
|
|
||||||
${stats.totalAmount != null ? `<div class="row"><span>Συνολικό Ποσό Παραγγελιών</span><span>${fmtEUR(stats.totalAmount)}</span></div>` : ''}
|
|
||||||
<div class="row"><span>Μέσος Αριθμός Ειδών ανά Παραγγελία</span><span>${avgItems}</span></div>
|
|
||||||
</div>
|
|
||||||
<h2>Top 3 Πιο Εκτυπωμένα Είδη</h2>
|
|
||||||
<div class="section">
|
|
||||||
<ol class="top-items" style="padding-left:16px">
|
|
||||||
${topItems.length ? topItems.map(([name, qty], i) => `<li>${i + 1}. ${name} — ${qty} τεμ.</li>`).join('') : '<li>—</li>'}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
${topWaiters.length ? `
|
|
||||||
<h2>Εκτυπώσεις ανά Σερβιτόρο</h2>
|
|
||||||
<div class="section">
|
|
||||||
${topWaiters.map(([name, cnt]) => `<div class="row"><span>${name}</span><span>${cnt}</span></div>`).join('')}
|
|
||||||
</div>` : ''}
|
|
||||||
`
|
|
||||||
} else if (printType === 'orders') {
|
|
||||||
body = `
|
|
||||||
<h1>Λίστα Παραγγελιών</h1>
|
|
||||||
<div class="section">
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
|
||||||
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
|
|
||||||
</div>
|
|
||||||
<h2>Παραγγελίες</h2>
|
|
||||||
${logs.map(j => `
|
|
||||||
<div class="order-block ${!j.success ? 'failed' : ''}">
|
|
||||||
<div class="order-header">
|
|
||||||
<span>#${j.order_id} · ${j.table || '—'}</span>
|
|
||||||
<span>${fmtDateTime(j.printed_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
|
|
||||||
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
|
|
||||||
${j.order_total != null ? `<div class="row"><span>Σύνολο</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
`
|
|
||||||
} else {
|
} else {
|
||||||
body = `
|
const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
|
||||||
<h1>Πλήρης Ανάλυση Εκτυπώσεων</h1>
|
const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
|
||||||
<div class="section">
|
periodRow = `<div class="row"><span>Από</span><span>${fromStr}</span></div>
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
<div class="row"><span>Έως</span><span>${toStr}</span></div>`
|
||||||
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
|
|
||||||
</div>
|
|
||||||
${logs.map(j => `
|
|
||||||
<div class="order-block ${!j.success ? 'failed' : ''}">
|
|
||||||
<div class="order-header">
|
|
||||||
<span>#${j.order_id} · ${j.table || '—'}</span>
|
|
||||||
<span>${fmtDateTime(j.printed_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
|
|
||||||
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
|
|
||||||
${(j.items || []).map(i => `<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`).join('')}
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${body}</body></html>`)
|
// ── 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.document.close()
|
||||||
win.focus()
|
win.focus()
|
||||||
win.print()
|
win.print()
|
||||||
@@ -221,6 +326,19 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
||||||
<div className="relative">
|
<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 topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
|
||||||
const periodLabel = mode === 'workday'
|
const periodLabel = mode === 'workday'
|
||||||
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
||||||
: `${from} → ${to}`
|
: `${fmtDateStr(from)} → ${fmtDateStr(to)}`
|
||||||
|
|
||||||
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }
|
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,323 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
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 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 { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } from '../shared/reportDesignTokens'
|
||||||
import { fmtEUR, fmtNum, CHART_PALETTE } from '../shared/reportDesignTokens'
|
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
// ─── SVG donut chart (inline, for print window) ───────────────────────────────
|
||||||
|
|
||||||
|
function svgDonut(categories, size = 200) {
|
||||||
|
const total = categories.reduce((s, c) => s + c.revenue, 0)
|
||||||
|
if (!total) return ''
|
||||||
|
const cx = size / 2, cy = size / 2, R = size * 0.42, r = size * 0.22
|
||||||
|
let angle = -Math.PI / 2
|
||||||
|
const slices = categories.map((c, i) => {
|
||||||
|
const frac = c.revenue / total
|
||||||
|
const a0 = angle, a1 = angle + frac * 2 * Math.PI
|
||||||
|
angle = a1
|
||||||
|
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0)
|
||||||
|
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1)
|
||||||
|
const xi0 = cx + r * Math.cos(a0), yi0 = cy + r * Math.sin(a0)
|
||||||
|
const xi1 = cx + r * Math.cos(a1), yi1 = cy + r * Math.sin(a1)
|
||||||
|
const large = frac > 0.5 ? 1 : 0
|
||||||
|
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
|
||||||
|
const d = `M${xi0},${yi0} L${x0},${y0} A${R},${R} 0 ${large},1 ${x1},${y1} L${xi1},${yi1} A${r},${r} 0 ${large},0 ${xi0},${yi0} Z`
|
||||||
|
return `<path d="${d}" fill="${color}" />`
|
||||||
|
}).join('')
|
||||||
|
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">${slices}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Print modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRINT_TYPES = [
|
||||||
|
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατηγορίες με τεμάχια, έσοδα και ποσοστό' },
|
||||||
|
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Κατηγορίες με γράφημα πίτας και αναλυτική εικόνα' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function CategoryPrintModal({ onClose, categories, allProducts, soldProducts, printers, queryParams, periodLabel }) {
|
||||||
|
const [printType, setPrintType] = useState('smart')
|
||||||
|
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||||
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
|
const printerOptions = [
|
||||||
|
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
|
||||||
|
...printers.map(p => ({ value: String(p.id), label: p.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
function periodRow() {
|
||||||
|
if (queryParams.business_day_id)
|
||||||
|
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
|
||||||
|
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
|
||||||
|
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
|
||||||
|
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowserPrint() {
|
||||||
|
const win = window.open('', '_blank', 'width=820,height=800')
|
||||||
|
if (!win) { onClose(); return }
|
||||||
|
|
||||||
|
const totalRev = categories.reduce((s, c) => s + c.revenue, 0)
|
||||||
|
const totalQty = categories.reduce((s, c) => s + c.units_sold, 0)
|
||||||
|
|
||||||
|
let body = ''
|
||||||
|
if (printType === 'smart') {
|
||||||
|
const rows = categories.map(c => {
|
||||||
|
const pctRev = c.pct_rev ?? c.pct ?? 0
|
||||||
|
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : '0'
|
||||||
|
return `<tr>
|
||||||
|
<td>${c.category_name}</td>
|
||||||
|
<td class="num">${fmtNum(c.units_sold)}</td>
|
||||||
|
<td class="num">${fmtEUR(c.revenue)}</td>
|
||||||
|
<td class="num">${pctRev}%</td>
|
||||||
|
<td class="num">${pctQty}%</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
body = `<table>
|
||||||
|
<thead><tr><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
|
||||||
|
</table>`
|
||||||
|
} else {
|
||||||
|
// Build sold-product lookup: product_id → {qty_sold, revenue, category_id}
|
||||||
|
const soldMap = {}
|
||||||
|
for (const p of soldProducts) soldMap[p.product_id] = p
|
||||||
|
// Group allProducts by category_id
|
||||||
|
const productsByCategory = {}
|
||||||
|
for (const ap of allProducts) {
|
||||||
|
if (!productsByCategory[ap.category_id]) productsByCategory[ap.category_id] = []
|
||||||
|
productsByCategory[ap.category_id].push(ap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const donut = svgDonut(categories)
|
||||||
|
const legend = categories.map((c, i) => {
|
||||||
|
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
|
||||||
|
const pctRev = c.pct_rev ?? c.pct ?? 0
|
||||||
|
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
|
||||||
|
return `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span><span class="legend-name">${c.category_name}</span><span class="legend-val">${pctRev}% έσ. / ${pctQty}% τεμ.</span></div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
// Full category blocks with per-product breakdown
|
||||||
|
const catBlocks = categories.map((c, i) => {
|
||||||
|
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
|
||||||
|
const pctRev = c.pct_rev ?? c.pct ?? 0
|
||||||
|
const pctQtyOfTotal = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
|
||||||
|
|
||||||
|
const catProducts = (productsByCategory[c.category_id] || [])
|
||||||
|
.map(ap => {
|
||||||
|
const sold = soldMap[ap.id]
|
||||||
|
return { name: ap.name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
|
||||||
|
})
|
||||||
|
.filter(p => p.qty > 0)
|
||||||
|
.sort((a, b) => b.qty - a.qty)
|
||||||
|
const catTotalQty = catProducts.reduce((s, p) => s + p.qty, 0)
|
||||||
|
const catTotalRev = catProducts.reduce((s, p) => s + p.revenue, 0)
|
||||||
|
|
||||||
|
const productRows = catProducts.map(p => {
|
||||||
|
const pctProdRev = catTotalRev ? (p.revenue / catTotalRev * 100).toFixed(1) : '0'
|
||||||
|
const pctProdQty = catTotalQty ? (p.qty / catTotalQty * 100).toFixed(1) : '0'
|
||||||
|
return `<tr>
|
||||||
|
<td class="prod-name">${p.name}</td>
|
||||||
|
<td class="num">${fmtNum(p.qty)}</td>
|
||||||
|
<td class="num">${fmtEUR(p.revenue)}</td>
|
||||||
|
<td class="num">${pctProdRev}%</td>
|
||||||
|
<td class="num">${pctProdQty}%</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="cat-full-block" style="border-left: 4px solid ${color}">
|
||||||
|
<div class="cat-full-header">
|
||||||
|
<span class="cat-full-name">${c.category_name}</span>
|
||||||
|
<span class="cat-full-rev">${fmtEUR(c.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cat-full-sub">${fmtNum(c.units_sold)} τεμ. · ${c.product_count} προϊόντα · ${pctRev}% εσόδων · ${pctQtyOfTotal}% τεμ.</div>
|
||||||
|
${catProducts.length ? `
|
||||||
|
<table class="prod-table">
|
||||||
|
<colgroup><col/><col style="width:52px"/><col style="width:64px"/><col style="width:48px"/><col style="width:48px"/></colgroup>
|
||||||
|
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
|
||||||
|
<tbody>${productRows}</tbody>
|
||||||
|
</table>` : '<div class="no-sales">Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο</div>'}
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
body = `
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="donut-wrap">${donut}</div>
|
||||||
|
<div class="legend">${legend}</div>
|
||||||
|
</div>
|
||||||
|
${catBlocks}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = printType === 'smart' ? 'Σύνοψη Κατηγοριών' : 'Πλήρης Ανάλυση Κατηγοριών'
|
||||||
|
const styles = `<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
|
||||||
|
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
|
||||||
|
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 2px 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 12px; font-family: 'Courier New', monospace; }
|
||||||
|
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
|
||||||
|
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
|
||||||
|
.num { text-align: right; font-weight: bold; }
|
||||||
|
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
|
||||||
|
.chart-row { display: flex; align-items: flex-start; gap: 24px; margin: 16px 0; }
|
||||||
|
.donut-wrap { flex-shrink: 0; }
|
||||||
|
.legend { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
|
||||||
|
.legend-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||||
|
.legend-dot { display: inline-block; width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
.legend-name { flex: 1; }
|
||||||
|
.legend-val { font-weight: bold; min-width: 40px; text-align: right; }
|
||||||
|
.cards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||||
|
.cat-card { padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 4px; }
|
||||||
|
.cat-name { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; color: #475569; margin-bottom: 4px; }
|
||||||
|
.cat-rev { font-size: 20px; font-weight: bold; font-family: 'Courier New', monospace; margin-bottom: 2px; }
|
||||||
|
.cat-sub { font-size: 10px; color: #64748b; }
|
||||||
|
.cat-full-block { padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 14px; }
|
||||||
|
.cat-full-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
|
||||||
|
.cat-full-name { font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.cat-full-rev { font-size: 13px; font-weight: bold; font-family: 'Courier New', monospace; color: #334155; }
|
||||||
|
.cat-full-sub { font-size: 10px; color: #64748b; margin-bottom: 8px; }
|
||||||
|
.prod-table { width: 100%; border-collapse: collapse; font-size: 10px; margin-top: 6px; table-layout: fixed; }
|
||||||
|
.prod-table th, .prod-table td { padding: 2px 4px; border-bottom: 1px dotted #e2e8f0; font-size: 10px; }
|
||||||
|
.prod-table thead th { border-bottom: 1px solid #cbd5e1; font-size: 9px; text-transform: uppercase; color: #64748b; font-weight: bold; }
|
||||||
|
.prod-table th.num, .prod-table td.num { text-align: right; font-weight: bold; }
|
||||||
|
.prod-table th:first-child, .prod-table td:first-child { text-align: left; }
|
||||||
|
.prod-name { color: #1e293b; }
|
||||||
|
.no-sales { font-size: 10px; color: #94a3b8; font-style: italic; margin-top: 4px; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
${periodRow()}
|
||||||
|
<div class="row"><span>Κατηγορίες</span><span>${categories.length}</span></div>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</body></html>`)
|
||||||
|
win.document.close()
|
||||||
|
win.focus()
|
||||||
|
win.print()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThermalPrint() {
|
||||||
|
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
|
||||||
|
if (queryParams.business_day_id) {
|
||||||
|
body.business_day_id = parseInt(queryParams.business_day_id, 10)
|
||||||
|
} else {
|
||||||
|
body.from_dt = queryParams.from
|
||||||
|
body.to_dt = queryParams.to
|
||||||
|
}
|
||||||
|
setPrinting(true)
|
||||||
|
try {
|
||||||
|
await client.post('/api/reports/print/categories', body)
|
||||||
|
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
|
||||||
|
} finally {
|
||||||
|
setPrinting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrint() {
|
||||||
|
if (targetPrinter === 'browser') handleBrowserPrint()
|
||||||
|
else handleThermalPrint()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
|
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Κατηγοριών</h2>
|
||||||
|
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PRINT_TYPES.map(pt => (
|
||||||
|
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
|
||||||
|
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-800">{pt.label}</div>
|
||||||
|
<div className="text-[12px] text-slate-500">{pt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
|
||||||
|
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
|
||||||
|
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
|
||||||
|
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
|
||||||
|
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
|
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CategoryPerformance() {
|
export default function CategoryPerformance() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [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({
|
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),
|
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
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 categories = data?.categories || []
|
||||||
const pieData = categories.map((c, i) => ({
|
const pieData = categories.map((c, i) => ({ name: c.category_name, value: c.revenue, color: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }))
|
||||||
name: c.category_name,
|
|
||||||
value: c.revenue,
|
const periodLabel = mode === 'workday'
|
||||||
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
|
? (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 (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} showChart /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
@@ -41,9 +329,20 @@ export default function CategoryPerformance() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar>
|
<FilterBar right={
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<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">
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
<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>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -82,7 +381,6 @@ export default function CategoryPerformance() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Panel title="Κατηγορίες" padded={false}>
|
<Panel title="Κατηγορίες" padded={false}>
|
||||||
<DataTable>
|
<DataTable>
|
||||||
@@ -90,12 +388,7 @@ export default function CategoryPerformance() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{categories.map((c, i) => (
|
{categories.map((c, i) => (
|
||||||
<TR key={c.category_id} striped>
|
<TR key={c.category_id} striped>
|
||||||
<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>
|
||||||
<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">{c.product_count}</TD>
|
||||||
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
|
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
|
||||||
@@ -109,6 +402,18 @@ export default function CategoryPerformance() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPrintModal && (
|
||||||
|
<CategoryPrintModal
|
||||||
|
onClose={() => setShowPrintModal(false)}
|
||||||
|
categories={categories}
|
||||||
|
allProducts={allProducts}
|
||||||
|
soldProducts={soldProducts}
|
||||||
|
printers={printers}
|
||||||
|
queryParams={queryParams}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,299 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
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 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 { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { 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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
// ─── 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() {
|
export default function ProductPerformance() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
const [catF, setCatF] = useState('all')
|
const [catF, setCatF] = useState('all')
|
||||||
const [chartMode, setChartMode] = useState('revenue')
|
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 = {
|
const queryParams = {
|
||||||
from: from + 'T00:00:00',
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
to: to + 'T23:59:59',
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
...(catF !== 'all' ? { category_id: catF } : {}),
|
...(catF !== 'all' ? { category_id: catF } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
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),
|
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
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 products = data?.products || []
|
||||||
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
|
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
|
||||||
|
|
||||||
const top10 = products.slice(0, 10).map((p, i) => ({
|
const top10 = [...products]
|
||||||
name: p.product_name,
|
.sort((a, b) => chartMode === 'revenue' ? b.revenue - a.revenue : b.qty_sold - a.qty_sold)
|
||||||
value: chartMode === 'revenue' ? p.revenue : p.qty_sold,
|
.slice(0, 10)
|
||||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
.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 (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} showChart /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
@@ -53,26 +306,32 @@ export default function ProductPerformance() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar right={
|
<FilterBar right={
|
||||||
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
|
||||||
|
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
|
||||||
|
</button>
|
||||||
|
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
|
||||||
|
</div>
|
||||||
}>
|
}>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
{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>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel
|
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`} right={
|
||||||
title="Top 10 Προϊόντα"
|
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
|
||||||
subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`}
|
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
|
||||||
right={
|
<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 className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
|
))}
|
||||||
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
|
</div>
|
||||||
<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 ? (
|
{top10.length === 0 ? (
|
||||||
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
<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)} />
|
<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} />
|
<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' }} />
|
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
|
||||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>
|
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>{top10.map((d, i) => <Cell key={i} fill={d.color} />)}</Bar>
|
||||||
{top10.map((d, i) => <Cell key={i} fill={d.color} />)}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +355,7 @@ export default function ProductPerformance() {
|
|||||||
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<THead>
|
<THead><TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH><TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH></THead>
|
||||||
<TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH>
|
|
||||||
<TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH>
|
|
||||||
</THead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{products.map(p => {
|
{products.map(p => {
|
||||||
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
|
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
|
||||||
@@ -121,6 +375,17 @@ export default function ProductPerformance() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPrintModal && (
|
||||||
|
<ProductPrintModal
|
||||||
|
onClose={() => setShowPrintModal(false)}
|
||||||
|
products={products}
|
||||||
|
allProducts={allProducts}
|
||||||
|
printers={printers}
|
||||||
|
queryParams={queryParams}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
|||||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -56,7 +56,7 @@ export default function RevenueTrends() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<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 ? (
|
{trends.length === 0 ? (
|
||||||
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,31 +1,222 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 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 { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { 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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
// ─── Print 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() {
|
export default function TableAnalytics() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [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({
|
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),
|
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
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 tables = data?.tables || []
|
||||||
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
|
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 (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
@@ -37,10 +228,26 @@ export default function TableAnalytics() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar right={
|
<FilterBar right={
|
||||||
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPrintModal(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
Εκτύπωση Σύνοψης
|
||||||
|
</button>
|
||||||
|
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
|
||||||
|
</div>
|
||||||
}>
|
}>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
{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>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -94,6 +301,16 @@ export default function TableAnalytics() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPrintModal && (
|
||||||
|
<TablePrintModal
|
||||||
|
onClose={() => setShowPrintModal(false)}
|
||||||
|
tables={tables}
|
||||||
|
printers={printers}
|
||||||
|
queryParams={queryParams}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
|
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
|
||||||
import client from '../../../api/client'
|
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 { Panel, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import StatCard from '../shared/StatCard'
|
import StatCard from '../shared/StatCard'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import { fmtNum } from '../shared/reportDesignTokens'
|
import { fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -17,17 +17,26 @@ const DOWS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυ
|
|||||||
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
|
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
|
||||||
|
|
||||||
export default function TrafficAnalytics() {
|
export default function TrafficAnalytics() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
|
||||||
const 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({
|
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),
|
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
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 byHour = useMemo(() => {
|
||||||
const hourMap = {}
|
const hourMap = {}
|
||||||
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
|
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
|
||||||
@@ -36,12 +45,6 @@ export default function TrafficAnalytics() {
|
|||||||
|
|
||||||
const byWeekday = data?.by_weekday || []
|
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 => ({
|
const chartData = byHour.map(h => ({
|
||||||
hour: `${String(h.hour).padStart(2, '0')}:00`,
|
hour: `${String(h.hour).padStart(2, '0')}:00`,
|
||||||
orders: h.orders,
|
orders: h.orders,
|
||||||
@@ -65,8 +68,15 @@ export default function TrafficAnalytics() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
{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>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -96,7 +106,7 @@ export default function TrafficAnalytics() {
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
|
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
|
||||||
<div className="grid grid-cols-7 gap-3">
|
<div className="grid grid-cols-7 gap-3">
|
||||||
{byWeekday.map((d, i) => {
|
{byWeekday.map((d) => {
|
||||||
const intensity = d.orders / maxByDow
|
const intensity = d.orders / maxByDow
|
||||||
return (
|
return (
|
||||||
<div key={d.day} className="flex flex-col items-center gap-2">
|
<div key={d.day} className="flex flex-col items-center gap-2">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
|
|||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { 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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -228,7 +228,7 @@ export default function WorkDaySummary() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{chartData.length > 0 && (
|
{chartData.length > 0 && (
|
||||||
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${from} → ${to}`}>
|
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${fmtDateStr(from)} → ${fmtDateStr(to)}`}>
|
||||||
<div style={{ height: 220 }}>
|
<div style={{ height: 220 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
|
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
|
||||||
|
|||||||
@@ -155,24 +155,168 @@ export function FilterSelect({ value, onChange, options, label, icon: Icon }) {
|
|||||||
|
|
||||||
// ── Date input ─────────────────────────────────────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<label className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}>
|
<div
|
||||||
<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}>
|
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"
|
||||||
<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" />
|
onMouseDown={e => e.preventDefault()}
|
||||||
</svg>
|
onClick={e => e.stopPropagation()}
|
||||||
{label && (
|
>
|
||||||
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
|
{/* Month navigation */}
|
||||||
{label}
|
<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>
|
</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>
|
||||||
|
{label && (
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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}
|
||||||
|
onCommit={onChange}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<input
|
</div>
|
||||||
type="date"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="bg-transparent outline-none text-[13px] text-slate-700 font-normal"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import StatCard from '../shared/StatCard'
|
|||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { 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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() {
|
function monthAgo() {
|
||||||
@@ -103,7 +103,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel
|
<Panel
|
||||||
title="Όλες οι Βάρδιες"
|
title="Όλες οι Βάρδιες"
|
||||||
subtitle={`${shifts.length} βάρδιες · ${from} → ${to}`}
|
subtitle={`${shifts.length} βάρδιες · ${fmtDateStr(from)} → ${fmtDateStr(to)}`}
|
||||||
padded={false}
|
padded={false}
|
||||||
>
|
>
|
||||||
{shifts.length === 0 ? (
|
{shifts.length === 0 ? (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
|||||||
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
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 today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -68,7 +68,7 @@ export default function StaffLeaderboard() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel title="Κορυφαίοι" subtitle={`${from} → ${to}`}>
|
<Panel title="Κορυφαίοι" subtitle={`${fmtDateStr(from)} → ${fmtDateStr(to)}`}>
|
||||||
{top3.length === 0 ? (
|
{top3.length === 0 ? (
|
||||||
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
|
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,71 +1,72 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
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 = {
|
export const DEFAULT_COLOURS = {
|
||||||
light: {
|
light: {
|
||||||
free: {
|
free: {
|
||||||
cardBg: '#d6d6d6',
|
cardBg: '#dde5ef',
|
||||||
badgeBg: '#e3e3e3',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#3b485e',
|
nameText: '#3d5270',
|
||||||
badgeText: '#adadad',
|
badgeText: '#3d5270',
|
||||||
},
|
},
|
||||||
mine: {
|
mine: {
|
||||||
cardBg: '#e83030',
|
cardBg: '#e8610a',
|
||||||
badgeBg: 'rgba(255,255,255,0.40)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#e8610a',
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
cardBg: '#ffbb29',
|
cardBg: '#FF8F60',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#FF8F60',
|
||||||
},
|
},
|
||||||
partially_paid: {
|
partially_paid: {
|
||||||
cardBg: '#e89230',
|
cardBg: '#FFDC67',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#d4a800',
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
cardBg: '#79ad38',
|
cardBg: '#81D264',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#81D264',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
free: {
|
free: {
|
||||||
cardBg: '#243044',
|
cardBg: '#243044',
|
||||||
badgeBg: 'rgba(26,35,50,0.50)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#94b8d4',
|
||||||
badgeText: '#adadad',
|
badgeText: '#94b8d4',
|
||||||
},
|
},
|
||||||
mine: {
|
mine: {
|
||||||
cardBg: '#e83030',
|
cardBg: '#e8610a',
|
||||||
badgeBg: 'rgba(255,255,255,0.40)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#e8610a',
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
cardBg: '#ffbb29',
|
cardBg: '#FF8F60',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#FF8F60',
|
||||||
},
|
},
|
||||||
partially_paid: {
|
partially_paid: {
|
||||||
cardBg: '#e89230',
|
cardBg: '#FFDC67',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#d4a800',
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
cardBg: '#79ad38',
|
cardBg: '#81D264',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#81D264',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,21 @@ export const fmtTime = (iso) => {
|
|||||||
}
|
}
|
||||||
export const fmtDate = (iso) => {
|
export const fmtDate = (iso) => {
|
||||||
if (!iso) return '—'
|
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) => {
|
export const fmtDateTime = (iso) => {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
|
|||||||
@@ -636,6 +636,11 @@ export default function TableCard({
|
|||||||
'4x3': Card4x3,
|
'4x3': Card4x3,
|
||||||
}[density] || Card2x2
|
}[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 (
|
return (
|
||||||
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
||||||
<button
|
<button
|
||||||
@@ -651,6 +656,21 @@ export default function TableCard({
|
|||||||
>
|
>
|
||||||
<CardComponent {...cardProps} />
|
<CardComponent {...cardProps} />
|
||||||
</button>
|
</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 && (
|
{showTip && flags.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user