Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc
This commit is contained in:
@@ -49,6 +49,7 @@ const EVENT_LABELS = {
|
||||
ORDER_OPENED: 'Άνοιγμα',
|
||||
ITEMS_ADDED: 'Προσθήκη',
|
||||
PAYMENT: 'Πληρωμή',
|
||||
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
|
||||
ORDER_CLOSED: 'Κλείσιμο',
|
||||
ORDER_CANCELLED: 'Ακύρωση',
|
||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||
@@ -60,30 +61,47 @@ function AuditTab({ order, waiterMap }) {
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{order.audit_logs.map(log => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
|
||||
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
|
||||
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||
</span>
|
||||
{order.audit_logs.map(log => {
|
||||
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
|
||||
const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
|
||||
const badgeClass = isDuplicate
|
||||
? 'bg-red-100 text-red-700'
|
||||
: isPayment ? 'bg-green-100 text-green-700'
|
||||
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
|
||||
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
// Show offline_at (real payment time) when available, else server created_at
|
||||
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
|
||||
return (
|
||||
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${badgeClass}`}>
|
||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||
</span>
|
||||
{isDuplicate && (
|
||||
<span className="block text-xs text-red-500 font-semibold mt-0.5">ΔΙΠΛΗ</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||
{log.amount != null && (
|
||||
<span className={`ml-2 font-semibold ${isDuplicate ? 'text-red-600' : 'text-green-700'}`}>
|
||||
€{log.amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{log.payment_method && (
|
||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs text-gray-400">{displayTime}</span>
|
||||
{log.offline_at && (
|
||||
<span className="block text-xs text-orange-400">offline</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||
{log.amount != null && (
|
||||
<span className="ml-2 font-semibold text-green-700">€{log.amount.toFixed(2)}</span>
|
||||
)}
|
||||
{log.payment_method && (
|
||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ function FlagDefsSection() {
|
||||
const qc = useQueryClient()
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [editForm, setEditForm] = useState({})
|
||||
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280' })
|
||||
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280', text_color: null })
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const { data: flags = [], isLoading } = useQuery({
|
||||
queryKey: ['flag-defs'],
|
||||
@@ -279,7 +279,7 @@ function FlagDefsSection() {
|
||||
})
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body) => client.post('/api/flags/defs', body),
|
||||
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280' }) },
|
||||
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280', text_color: null }) },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
@@ -294,7 +294,7 @@ function FlagDefsSection() {
|
||||
})
|
||||
function startEdit(flag) {
|
||||
setEditingId(flag.id)
|
||||
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', sort_order: flag.sort_order })
|
||||
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', text_color: flag.text_color || null, sort_order: flag.sort_order })
|
||||
}
|
||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
|
||||
return (
|
||||
@@ -320,6 +320,13 @@ function FlagDefsSection() {
|
||||
style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Χρώμα γραφής:</span>
|
||||
{[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => (
|
||||
<button key={opt.label + opt.text} onClick={() => setNewForm(f => ({ ...f, text_color: opt.val }))}
|
||||
style={{ width: 28, height: 28, borderRadius: 6, background: opt.bg, color: opt.text, fontSize: 14, fontWeight: 700, border: newForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
|
||||
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
|
||||
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||||
@@ -342,6 +349,12 @@ function FlagDefsSection() {
|
||||
style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||
{[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => (
|
||||
<button key={opt.text} onClick={() => setEditForm(f => ({ ...f, text_color: opt.val }))}
|
||||
style={{ width: 24, height: 24, borderRadius: 6, background: editForm.color || '#6b7280', color: opt.text, fontSize: 13, fontWeight: 700, border: editForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>Α</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
|
||||
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓</button>
|
||||
<button onClick={() => setEditingId(null)}
|
||||
|
||||
@@ -4,11 +4,9 @@ import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
|
||||
// ── Font option definitions ────────────────────────────────────────────────
|
||||
// Value encodes: "ESC_BANG_BYTE:BOLD" where BOLD is 0 or 1
|
||||
// ESC ! correct bit map for TP850UE:
|
||||
// bit3 (0x08) = bold
|
||||
// bit4 (0x10) = double-height
|
||||
// bit5 (0x20) = double-width
|
||||
// Value encodes: "SIZE:BOLD:CAPS"
|
||||
// SIZE: ESC ! base byte — 0=normal, 16=tall, 32=wide, 48=tall+wide
|
||||
// BOLD: 0|1 CAPS: 0|1
|
||||
const FONT_SIZE_OPTIONS = [
|
||||
{ size: '0', label: 'Μικρά' },
|
||||
{ size: '16', label: 'Ψηλά' },
|
||||
@@ -16,12 +14,13 @@ const FONT_SIZE_OPTIONS = [
|
||||
{ size: '48', label: 'Ψηλά και Πλατιά' },
|
||||
]
|
||||
|
||||
// We store the value as "SIZE:BOLD" e.g. "16:1" or "0:0"
|
||||
function encodeFont(size, bold) { return `${size}:${bold ? '1' : '0'}` }
|
||||
function encodeFont(size, bold, caps) {
|
||||
return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
|
||||
}
|
||||
function decodeFont(val) {
|
||||
if (!val) return { size: '0', bold: false }
|
||||
const [size, bold] = val.split(':')
|
||||
return { size: size ?? '0', bold: bold === '1' }
|
||||
if (!val) return { size: '0', bold: false, caps: false }
|
||||
const [size, bold, caps] = val.split(':')
|
||||
return { size: size ?? '0', bold: bold === '1', caps: caps === '1' }
|
||||
}
|
||||
|
||||
const DIVIDER_OPTIONS = [
|
||||
@@ -31,26 +30,21 @@ const DIVIDER_OPTIONS = [
|
||||
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
||||
]
|
||||
|
||||
const FONT_FIELDS = [
|
||||
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Κάθε πιάτο/ποτό στο ticket κουζίνας' },
|
||||
{ key: 'print.font_options', label: 'Επιλογές / Τροποποιητές', sub: 'Extras, αφαιρέσεις, σημειώσεις' },
|
||||
{ key: 'print.font_table', label: 'Τραπέζι & Σερβιτόρος', sub: 'Αριθμός τραπεζιού, όνομα σερβιτόρου, ώρα' },
|
||||
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: 'Η επικεφαλίδα "Παραγγελία #Χ"' },
|
||||
{ key: 'print.font_header', label: 'Κεφαλίδα / Τίτλος', sub: 'Τίτλοι ενοτήτων, κεφαλίδες αναφορών' },
|
||||
]
|
||||
|
||||
const FONT_DEFAULTS = {
|
||||
'print.font_item_name': '16:0',
|
||||
'print.font_options': '0:0',
|
||||
'print.font_table': '16:0',
|
||||
'print.font_order_number': '48:1',
|
||||
'print.font_header': '48:1',
|
||||
'print.font_order_number': '48:1:0',
|
||||
'print.font_meta': '0:0:0',
|
||||
'print.font_item_name': '16:1:0',
|
||||
'print.font_quick': '0:0:0',
|
||||
'print.font_pref': '0:0:0',
|
||||
'print.font_extra': '0:0:0',
|
||||
'print.font_ingredient': '0:0:0',
|
||||
'print.font_item_note': '0:0:0',
|
||||
'print.font_order_note': '0:1:0',
|
||||
'print.divider_style': 'dash',
|
||||
'print.ticket_mode': 'detailed',
|
||||
}
|
||||
|
||||
// ── Preview box ────────────────────────────────────────────────────────────
|
||||
// Fixed height tall enough for the largest option (Ψηλά και Πλατιά).
|
||||
// All rows share the same height so columns stay aligned.
|
||||
// ── Preview ────────────────────────────────────────────────────────────────
|
||||
const PREVIEW_W = 200
|
||||
const PREVIEW_H = 50
|
||||
|
||||
@@ -61,7 +55,7 @@ const sizeStyle = {
|
||||
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
|
||||
}
|
||||
|
||||
function FontPreview({ size, bold }) {
|
||||
function FontPreview({ size, bold, caps }) {
|
||||
const s = sizeStyle[size] ?? sizeStyle['0']
|
||||
return (
|
||||
<div style={{
|
||||
@@ -80,30 +74,66 @@ function FontPreview({ size, bold }) {
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}>
|
||||
SAMPLE
|
||||
{caps ? 'SAMPLE' : 'Sample'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single font row ────────────────────────────────────────────────────────
|
||||
function FontRow({ field, value, onChange, isPending }) {
|
||||
const { size, bold } = decodeFont(value)
|
||||
// ── Toggle button (shared) ─────────────────────────────────────────────────
|
||||
function ToggleBtn({ active, onClick, disabled, label }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
|
||||
border: `1.5px solid ${active ? '#3758c9' : '#dfe2e6'}`,
|
||||
background: active ? '#eff3ff' : 'white',
|
||||
color: active ? '#3758c9' : '#6b7280',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: `2px solid ${active ? '#3758c9' : '#9ca3af'}`,
|
||||
background: active ? '#3758c9' : 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{active && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}>✓</span>}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold)) }
|
||||
function handleBold() { onChange(field.key, encodeFont(size, !bold)) }
|
||||
// ── Single font row ────────────────────────────────────────────────────────
|
||||
function FontRow({ field, value, onChange, isPending, nested = false }) {
|
||||
const { size, bold, caps } = decodeFont(value)
|
||||
|
||||
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold, caps)) }
|
||||
function handleBold() { onChange(field.key, encodeFont(size, !bold, caps)) }
|
||||
function handleCaps() { onChange(field.key, encodeFont(size, bold, !caps)) }
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 20px', borderBottom: '1px solid #f4f4f2',
|
||||
padding: nested ? '10px 20px 10px 36px' : '14px 20px',
|
||||
borderBottom: '1px solid #f4f4f2',
|
||||
background: nested ? '#fafafa' : 'white',
|
||||
}}>
|
||||
{nested && (
|
||||
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}>└</span>
|
||||
)}
|
||||
{/* Label */}
|
||||
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||
<span style={{ fontSize: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||
{field.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||
{field.sub && (
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size dropdown */}
|
||||
@@ -123,31 +153,28 @@ function FontRow({ field, value, onChange, isPending }) {
|
||||
</select>
|
||||
|
||||
{/* Bold toggle */}
|
||||
<button
|
||||
onClick={handleBold}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
|
||||
border: `1.5px solid ${bold ? '#3758c9' : '#dfe2e6'}`,
|
||||
background: bold ? '#eff3ff' : 'white',
|
||||
color: bold ? '#3758c9' : '#6b7280',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: `2px solid ${bold ? '#3758c9' : '#9ca3af'}`,
|
||||
background: bold ? '#3758c9' : 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{bold && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}>✓</span>}
|
||||
</span>
|
||||
ΕΝΤΟΝΑ
|
||||
</button>
|
||||
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
|
||||
|
||||
{/* Caps toggle */}
|
||||
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
|
||||
|
||||
{/* Preview */}
|
||||
<FontPreview size={size} bold={bold} />
|
||||
<FontPreview size={size} bold={bold} caps={caps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Subgroup header row ────────────────────────────────────────────────────
|
||||
function SubgroupHeader({ label }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 20px 6px',
|
||||
borderBottom: '1px solid #f4f4f2',
|
||||
background: '#f9fafb',
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -181,10 +208,10 @@ function DividerRow({ value, onChange, isPending }) {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* spacer to align with bold button column */}
|
||||
<div style={{ width: 87, flexShrink: 0 }} />
|
||||
{/* spacer to align with bold+caps column */}
|
||||
<div style={{ width: 194, flexShrink: 0 }} />
|
||||
|
||||
{/* Preview — same fixed size as font previews */}
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
background: '#1a1a1a', borderRadius: 8,
|
||||
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||
@@ -202,10 +229,127 @@ function DividerRow({ value, onChange, isPending }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Ticket mode section ────────────────────────────────────────────────────
|
||||
function TicketModeSection({ value, onChange, isPending, printers }) {
|
||||
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
||||
const [printing, setPrinting] = useState(false)
|
||||
|
||||
// Auto-select first active printer
|
||||
useEffect(() => {
|
||||
if (printers.length > 0 && !selectedPrinter) {
|
||||
const first = printers.find(p => p.is_active) ?? printers[0]
|
||||
setSelectedPrinter(first.id)
|
||||
}
|
||||
}, [printers])
|
||||
|
||||
async function handleTestOrder() {
|
||||
if (!selectedPrinter) return
|
||||
setPrinting(true)
|
||||
try {
|
||||
const res = await client.post(`/api/system/printers/test-order?printer_id=${selectedPrinter}`)
|
||||
if (res.data.success) toast.success('Test order στάλθηκε!')
|
||||
else toast.error(`Σφάλμα: ${res.data.error}`)
|
||||
} catch {
|
||||
toast.error('Σφάλμα επικοινωνίας')
|
||||
} finally {
|
||||
setPrinting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Τύπος Εκτύπωσης</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, padding: '16px 20px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{
|
||||
key: 'detailed',
|
||||
title: 'Αναλυτικό',
|
||||
desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.',
|
||||
},
|
||||
{
|
||||
key: 'compact',
|
||||
title: 'Συμπαγές',
|
||||
desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.',
|
||||
},
|
||||
].map(opt => {
|
||||
const active = value === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => onChange('print.ticket_mode', opt.key)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
|
||||
borderRadius: 10, cursor: 'pointer',
|
||||
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
|
||||
background: active ? '#eff3ff' : 'white',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 4 }}>
|
||||
{opt.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>{opt.desc}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Test order button */}
|
||||
<button
|
||||
onClick={handleTestOrder}
|
||||
disabled={printing || !selectedPrinter}
|
||||
style={{
|
||||
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
|
||||
borderRadius: 10, cursor: printing || !selectedPrinter ? 'default' : 'pointer',
|
||||
border: '2px solid #e5e7eb',
|
||||
background: printing ? '#f9fafb' : 'white',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: printing ? '#9ca3af' : '#111315', marginBottom: 4 }}>
|
||||
{printing ? 'Εκτύπωση…' : 'Δοκιμαστική Εκτύπωση'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||
Εκτυπώνει fake παραγγελία με όλους τους τύπους επιλογών για προεπισκόπηση ρυθμίσεων.
|
||||
</div>
|
||||
</div>
|
||||
{printers.length > 0 && (
|
||||
<div style={{ marginTop: 10 }} onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
value={selectedPrinter ?? ''}
|
||||
onChange={e => setSelectedPrinter(Number(e.target.value))}
|
||||
disabled={printing}
|
||||
style={{
|
||||
width: '100%', height: 32, borderRadius: 6,
|
||||
border: '1px solid #dfe2e6', background: 'white',
|
||||
padding: '0 8px', fontSize: 12, color: '#374151', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{printers.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{printers.length === 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444' }}>
|
||||
Δεν υπάρχουν εκτυπωτές
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Printers section ───────────────────────────────────────────────────────
|
||||
|
||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||
|
||||
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
|
||||
|
||||
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||
@@ -284,7 +428,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
||||
opacity: printer.is_active ? 1 : 0.5,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Enable/disable toggle */}
|
||||
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
|
||||
style={{
|
||||
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
|
||||
@@ -297,7 +440,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{/* Name + connection info */}
|
||||
<div style={{ flex: 1, minWidth: 120 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
|
||||
@@ -306,7 +448,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
||||
</div>
|
||||
|
||||
{/* Reachability badge */}
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
|
||||
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
|
||||
@@ -315,7 +456,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
||||
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<button onClick={() => onTest(printer.id)} disabled={testPending}
|
||||
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||
Test Print
|
||||
@@ -422,6 +562,39 @@ function PrintersSection() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Font groups definition ─────────────────────────────────────────────────
|
||||
const FONT_GROUPS = [
|
||||
{
|
||||
group: 'Αριθμός Παραγγελίας',
|
||||
fields: [
|
||||
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: '"Παραγγελια #42" — η επικεφαλίδα του ticket' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Επικεφαλίδα Ticket',
|
||||
fields: [
|
||||
{ key: 'print.font_meta', label: 'Τραπέζι · Σερβιτόρος · Ώρα', sub: 'Γραμμές ταυτότητας κάτω από τον αριθμό' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Αντικείμενα',
|
||||
fields: [
|
||||
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Το κυρίως πιάτο/ποτό — γραμμή dot-leader' },
|
||||
{ key: 'print.font_quick', label: '* Quick Options', sub: 'Γρήγορες επιλογές ( * )' },
|
||||
{ key: 'print.font_pref', label: '> Προτιμήσεις', sub: 'Επιλογές preference sets ( > )' },
|
||||
{ key: 'print.font_extra', label: '+ Extras', sub: 'Πρόσθετα / τροποποιητές ( + )' },
|
||||
{ key: 'print.font_ingredient', label: '- Αφαιρέσεις', sub: 'ΧΩΡΙΣ: συστατικά ( - )' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Σημειώσεις',
|
||||
fields: [
|
||||
{ key: 'print.font_item_note', label: '(!) Σημείωση Αντικειμένου', sub: 'Free-text σημείωση ανά πιάτο' },
|
||||
{ key: 'print.font_order_note', label: 'Σημειώσεις Παραγγελίας', sub: 'Η γενική σημείωση της παραγγελίας' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Main tab ───────────────────────────────────────────────────────────────
|
||||
export default function PrintFontsTab() {
|
||||
const qc = useQueryClient()
|
||||
@@ -432,6 +605,12 @@ export default function PrintFontsTab() {
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers-all'],
|
||||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
|
||||
@@ -448,28 +627,44 @@ export default function PrintFontsTab() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* 1. Printers */}
|
||||
<PrintersSection />
|
||||
|
||||
{/* Font sizes card */}
|
||||
{/* 2. Ticket mode */}
|
||||
<TicketModeSection
|
||||
value={val('print.ticket_mode')}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
printers={printers}
|
||||
/>
|
||||
|
||||
{/* 3. Font sizes — grouped */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Οι αλλαγές εφαρμόζονται αμέσως στην επόμενη εκτύπωση.
|
||||
Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.
|
||||
</p>
|
||||
</div>
|
||||
{FONT_FIELDS.map(field => (
|
||||
<FontRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={val(field.key)}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
/>
|
||||
|
||||
{FONT_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<SubgroupHeader label={group.group} />
|
||||
{group.fields.map((field, idx) => (
|
||||
<FontRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={val(field.key)}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
nested={group.fields.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider style card */}
|
||||
{/* 4. Divider style */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||
|
||||
Reference in New Issue
Block a user