import React, { useState, useEffect } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' import client from '../../../api/client' // ── Font option definitions ──────────────────────────────────────────────── // 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: 'Ψηλά' }, { size: '32', label: 'Πλατιά' }, { size: '48', label: 'Ψηλά και Πλατιά' }, ] function encodeFont(size, bold, caps) { return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}` } function decodeFont(val) { 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 = [ { value: 'dash', label: 'Παύλες ( - )', chars: '-------------------' }, { value: 'equals', label: 'Ίσον ( = )', chars: '===================' }, { value: 'star', 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 = { '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.dot_style': 'dot_space:0:0', 'print.ticket_mode': 'detailed', 'print.beep_on_ticket': 'true', 'print.beep_pattern': 'double', } // ── Preview ──────────────────────────────────────────────────────────────── const PREVIEW_W = 200 const PREVIEW_H = 50 const sizeStyle = { '0': { fontSize: 13, scaleY: 1, scaleX: 1 }, '16': { fontSize: 13, scaleY: 1.9, scaleX: 1 }, '32': { fontSize: 13, scaleY: 1, scaleX: 1.9 }, '48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 }, } function FontPreview({ size, bold, caps }) { const s = sizeStyle[size] ?? sizeStyle['0'] return (
{caps ? 'SAMPLE' : 'Sample'}
) } // ── Toggle button (shared) ───────────────────────────────────────────────── function ToggleBtn({ active, onClick, disabled, label }) { return ( ) } // ── 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 (
{nested && ( )} {/* Label */}
{field.label} {field.sub && ( {field.sub} )}
{/* Size dropdown */} {/* Bold toggle */} {/* Caps toggle */} {/* Preview */}
) } // ── Subgroup header row ──────────────────────────────────────────────────── function SubgroupHeader({ label }) { return (
{label}
) } // ── Divider row ──────────────────────────────────────────────────────────── function DividerRow({ value, onChange, isPending }) { return (
Στυλ Διαχωριστικού Ανάμεσα στις ενότητες κάθε ticket
{/* spacer to align with bold+caps column */}
{/* Preview */}
{value === 'empty' ? (κενή γραμμή) : {DIVIDER_OPTIONS.find(o => o.value === value)?.chars} }
) } // ── 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 (
Στυλ Dots Χαρακτήρας · μέγεθος · έντονο για τη γραμμή dot-leader
{/* Style dropdown */} {/* Size dropdown */} {/* Bold toggle */} {/* Preview */}
Esp {selected.preview} x2
) } // ── 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 (

Τύπος Εκτύπωσης

Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας.

{[ { key: 'detailed', title: 'Αναλυτικό', desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.', }, { key: 'compact', title: 'Συμπαγές', desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.', }, ].map(opt => { const active = value === opt.key return ( ) })} {/* Test order button */}
) } // ── Beep section ─────────────────────────────────────────────────────────── const BEEP_PRESETS = [ { id: 'off', label: 'Χωρίς Ήχο', desc: 'Χωρίς beep κατά την εκτύπωση' }, { id: 'single', label: 'Μονό', desc: '1× παλμός 200ms' }, { id: 'double', label: 'Διπλό', desc: '2× σύντομοι παλμοί (προτεινόμενο)' }, { id: 'triple', label: 'Τριπλό', desc: '3× σύντομοι παλμοί — πιο εντονο' }, { id: 'long', label: 'Μακρύ', desc: '1× παλμός 500ms' }, { id: 'custom', label: 'Προσαρμοσμένο', desc: 'Ορίστε τη διάρκεια χειροκίνητα' }, ] // Parse beep settings → { preset, n1, n2, n3 } function decodeBeep(patternVal, enabledVal) { if (enabledVal === 'false') return { preset: 'off', n1: 2, n2: 2, n3: 1 } if (!patternVal || patternVal === 'double') return { preset: 'double', n1: 1, n2: 1, n3: 2 } if (patternVal.startsWith('custom:')) { const [, n1, n2, n3] = patternVal.split(':') return { preset: 'custom', n1: parseInt(n1) || 2, n2: parseInt(n2) || 2, n3: parseInt(n3) || 1 } } const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] } const [n1, n2, n3] = presetMap[patternVal] ?? [1,1,2] return { preset: patternVal, n1, n2, n3 } } function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }) { const { preset: initPreset, n1: iN1, n2: iN2, n3: iN3 } = decodeBeep(beepPattern, beepEnabled) const [preset, setPreset] = useState(initPreset) const [n1, setN1] = useState(iN1) const [n2, setN2] = useState(iN2) const [n3, setN3] = useState(iN3) const [testPrinter, setTestPrinter] = useState(null) const [testing, setTesting] = useState(false) useEffect(() => { if (printers.length > 0 && !testPrinter) { setTestPrinter((printers.find(p => p.is_active) ?? printers[0]).id) } }, [printers]) // Sync local state if parent settings change (e.g. after save) useEffect(() => { const decoded = decodeBeep(beepPattern, beepEnabled) setPreset(decoded.preset) setN1(decoded.n1); setN2(decoded.n2); setN3(decoded.n3) }, [beepPattern, beepEnabled]) function handlePresetChange(id) { setPreset(id) if (id === 'off') { onChange('print.beep_on_ticket', 'false') } else if (id === 'custom') { onChange('print.beep_on_ticket', 'true') onChange('print.beep_pattern', `custom:${n1}:${n2}:${n3}`) } else { onChange('print.beep_on_ticket', 'true') onChange('print.beep_pattern', id) } } function handleCustomChange(field, raw) { const v = Math.max(1, Math.min(30, parseInt(raw) || 1)) const next = { n1, n2, n3, [field]: v } if (field === 'n1') setN1(v) if (field === 'n2') setN2(v) if (field === 'n3') setN3(v) onChange('print.beep_pattern', `custom:${next.n1}:${next.n2}:${next.n3}`) } // Compute beep bytes from current state for test function getBeepBytes() { if (preset === 'custom') return { n1, n2, n3 } const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] } const [bn1, bn2, bn3] = presetMap[preset] ?? [1,1,2] return { n1: bn1, n2: bn2, n3: bn3 } } async function handleTestBeep() { if (!testPrinter || preset === 'off') return const { n1: b1, n2: b2, n3: b3 } = getBeepBytes() setTesting(true) try { const res = await client.post(`/api/system/printers/test-beep?printer_id=${testPrinter}&n1=${b1}&n2=${b2}&n3=${b3}`) if (res.data.success) toast.success('Beep στάλθηκε!') else toast.error(`Σφάλμα: ${res.data.error}`) } catch { toast.error('Σφάλμα επικοινωνίας') } finally { setTesting(false) } } return (

Ήχος Εκτυπωτή (Beep)

Ο εκτυπωτής χτυπά για να ειδοποιεί την κουζίνα σε κάθε νέα παραγγελία.

{/* Preset grid */}
{BEEP_PRESETS.map(opt => { const active = preset === opt.id return ( ) })}
{/* Custom fields */} {preset === 'custom' && (
{[ { field: 'n1', label: 'Διάρκεια ON (×100ms)', val: n1, hint: 'π.χ. 3 = 300ms' }, { field: 'n2', label: 'Διάρκεια OFF (×100ms)', val: n2, hint: 'Διάλλειμα μεταξύ παλμών' }, { field: 'n3', label: 'Επαναλήψεις', val: n3, hint: 'Πόσες φορές' }, ].map(({ field, label, val, hint }) => (
handleCustomChange(field, e.target.value)} disabled={isPending} style={{ ...inputStyle, width: '100%' }} /> {hint}
))}
)} {/* Test beep */}
Δοκιμαστικό Beep: {printers.length > 0 ? ( <> {preset === 'off' && ( Ο ήχος είναι απενεργοποιημένος )} ) : ( Δεν υπάρχουν εκτυπωτές )}
) } // ── Printers section ─────────────────────────────────────────────────────── const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }] const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, is_active: true } function PrinterForm({ initial, onSave, onCancel, isPending }) { const [form, setForm] = useState(initial ?? EMPTY_FORM) function set(k, v) { setForm(f => ({ ...f, [k]: v })) } return (
set('name', e.target.value)} placeholder="π.χ. Κουζίνα" style={inputStyle} />
set('ip_address', e.target.value)} placeholder="10.98.20.25" style={inputStyle} />
set('port', parseInt(e.target.value) || 9100)} type="number" style={inputStyle} />
set('line_width', parseInt(e.target.value) || 48)} type="number" min={20} max={80} style={inputStyle} />
) } const inputStyle = { height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%', } const btnPrimary = { height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', } const btnSecondary = { height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151', } const btnDanger = { height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626', } function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) { const [reachable, setReachable] = useState(null) useEffect(() => { let cancelled = false client.get('/api/system/status').then(r => { if (cancelled) return const match = r.data.printers?.find(p => p.id === printer.id) if (match) setReachable(match.reachable) }).catch(() => {}) return () => { cancelled = true } }, [printer.id]) return (
{printer.name} {printer.ip_address}:{printer.port} — {printer.protocol} — {printer.line_width ?? 48} χαρ.
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
) } function PrintersSection() { const qc = useQueryClient() const [showNew, setShowNew] = useState(false) const [editingId, setEditingId] = useState(null) const { data: printers = [], isLoading } = useQuery({ queryKey: ['printers-all'], queryFn: () => client.get('/api/system/printers').then(r => r.data), staleTime: 15_000, }) const createMut = useMutation({ mutationFn: body => client.post('/api/system/printers', body), onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) }, onError: () => toast.error('Σφάλμα δημιουργίας'), }) const updateMut = useMutation({ mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body), onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) }, onError: () => toast.error('Σφάλμα αποθήκευσης'), }) const deleteMut = useMutation({ mutationFn: id => client.delete(`/api/system/printers/${id}`), onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) }, onError: () => toast.error('Σφάλμα διαγραφής'), }) const testMut = useMutation({ mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`), onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`), onError: () => toast.error('Σφάλμα επικοινωνίας'), }) function handleToggle(printer) { updateMut.mutate({ id: printer.id, is_active: !printer.is_active }) } return (

Εκτυπωτές

Διαχείριση εκτυπωτών του συστήματος

{showNew && (
createMut.mutate(form)} onCancel={() => setShowNew(false)} isPending={createMut.isPending} />
)} {isLoading &&

Φόρτωση…

} {!isLoading && printers.length === 0 && !showNew && (

Δεν υπάρχουν εκτυπωτές ακόμα.

)} {printers.map(printer => ( editingId === printer.id ? (
updateMut.mutate({ id: printer.id, ...form })} onCancel={() => setEditingId(null)} isPending={updateMut.isPending} />
) : ( { setEditingId(p.id); setShowNew(false) }} onDelete={id => deleteMut.mutate(id)} onTest={id => testMut.mutate(id)} onToggle={handleToggle} testPending={testMut.isPending} /> ) ))}
) } // ── 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() const { data: settings, isLoading } = useQuery({ queryKey: ['pos-settings'], queryFn: () => client.get('/api/settings/').then(r => r.data), 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'] }) }, onError: () => toast.error('Σφάλμα αποθήκευσης'), }) function val(key) { return settings?.[key]?.value ?? FONT_DEFAULTS[key] } function handleChange(key, value) { updateMut.mutate({ key, value }) } if (isLoading) { return
Φόρτωση…
} return (
{/* 1. Printers */} {/* 2. Ticket mode */} {/* 3. Beep settings */} {/* 4. Font sizes */}

Μεγέθη Γραμματοσειράς

Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.

{FONT_GROUPS.map(group => (
{group.fields.map((field) => ( 1} /> {field.key === 'print.font_ingredient' && ( )} ))}
))}
{/* 5. Divider style */}

Διαχωριστικές Γραμμές

Σημείωση: Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48. Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
) }