Files
xenia-pos-local/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
bonamin ac7ec45279 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>
2026-06-07 20:28:23 +03:00

999 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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: 'Arial, Helvetica, sans-serif',
fontSize: s.fontSize,
fontWeight: bold ? 800 : 400,
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
transformOrigin: 'center',
whiteSpace: 'nowrap',
display: 'block',
}}>
{caps ? 'SAMPLE' : 'Sample'}
</span>
</div>
)
}
// ── 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>
)
}
// ── 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: 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: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
{field.label}
</span>
{field.sub && (
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
)}
</div>
{/* 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="ΕΝΤΟΝΑ" />
{/* Caps toggle */}
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
{/* Preview */}
<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>
)
}
// ── Divider row ────────────────────────────────────────────────────────────
function DividerRow({ value, onChange, isPending }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 20px',
}}>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
Στυλ Διαχωριστικού
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>Ανάμεσα στις ενότητες κάθε ticket</span>
</div>
<select
value={value}
onChange={e => onChange('print.divider_style', e.target.value)}
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,
}}
>
{DIVIDER_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* spacer to align with bold+caps column */}
<div style={{ width: 194, flexShrink: 0 }} />
{/* Preview */}
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{value === 'empty'
? <span style={{ color: '#6b7280', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif' }}>(κενή γραμμή)</span>
: <span style={{ color: '#f5f5f5', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif', letterSpacing: 2 }}>
{DIVIDER_OPTIONS.find(o => o.value === value)?.chars}
</span>
}
</div>
</div>
)
}
// ── Dot style row ─────────────────────────────────────────────────────────
function decodeDotStyle(val) {
if (!val) return { style: 'dot_space', size: '0', bold: false }
const [style, size, bold] = val.split(':')
return { style: style ?? 'dot_space', size: size ?? '0', bold: bold === '1' }
}
function encodeDotStyle(style, size, bold) {
return `${style}:${size}:${bold ? '1' : '0'}`
}
function DotStyleRow({ value, onChange, isPending }) {
const { style, size, bold } = decodeDotStyle(value)
const selected = DOT_STYLE_OPTIONS.find(o => o.value === style) ?? DOT_STYLE_OPTIONS[0]
const s = sizeStyle[size] ?? sizeStyle['0']
function handleStyle(e) { onChange('print.dot_style', encodeDotStyle(e.target.value, size, bold)) }
function handleSize(e) { onChange('print.dot_style', encodeDotStyle(style, e.target.value, bold)) }
function handleBold() { onChange('print.dot_style', encodeDotStyle(style, size, !bold)) }
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 20px 10px 36px',
borderBottom: '1px solid #f4f4f2',
background: '#fafafa',
}}>
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}></span>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
Στυλ Dots
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>Χαρακτήρας · μέγεθος · έντονο για τη γραμμή dot-leader</span>
</div>
{/* Style dropdown */}
<select
value={style}
onChange={handleStyle}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{DOT_STYLE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* Size dropdown */}
<select
value={size}
onChange={handleSize}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{FONT_SIZE_OPTIONS.map(o => (
<option key={o.size} value={o.size}>{o.label}</option>
))}
</select>
{/* Bold toggle */}
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
{/* Preview */}
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
<span style={{
color: '#f5f5f5',
fontFamily: 'monospace',
fontSize: s.fontSize,
fontWeight: bold ? 800 : 400,
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
transformOrigin: 'center',
whiteSpace: 'nowrap',
display: 'block',
}}>
Esp {selected.preview} x2
</span>
</div>
</div>
)
}
// ── Ticket mode section ────────────────────────────────────────────────────
function TicketModeSection({ value, onChange, isPending, printers }) {
const [selectedPrinter, setSelectedPrinter] = useState(null)
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>
)
}
// ── 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 (
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Ήχος Εκτυπωτή (Beep)</h2>
<p className="text-xs text-gray-400 mt-0.5">
Ο εκτυπωτής χτυπά για να ειδοποιεί την κουζίνα σε κάθε νέα παραγγελία.
</p>
</div>
{/* Preset grid */}
<div style={{ display: 'flex', gap: 10, padding: '16px 20px', flexWrap: 'wrap' }}>
{BEEP_PRESETS.map(opt => {
const active = preset === opt.id
return (
<button
key={opt.id}
onClick={() => handlePresetChange(opt.id)}
disabled={isPending}
style={{
flex: '1 1 140px', textAlign: 'left', padding: '12px 14px',
borderRadius: 10, cursor: isPending ? 'default' : 'pointer',
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
background: active ? '#eff3ff' : 'white',
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 3 }}>
{opt.label}
</div>
<div style={{ fontSize: 11, color: '#6b7280', lineHeight: 1.4 }}>{opt.desc}</div>
</button>
)
})}
</div>
{/* Custom fields */}
{preset === 'custom' && (
<div style={{ padding: '14px 20px', display: 'flex', gap: 20, alignItems: 'flex-end', flexWrap: 'wrap', background: '#fafafa' }}>
{[
{ 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 }) => (
<div key={field} style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 140px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>{label}</label>
<input
type="number" min={1} max={30} value={val}
onChange={e => handleCustomChange(field, e.target.value)}
disabled={isPending}
style={{ ...inputStyle, width: '100%' }}
/>
<span style={{ fontSize: 10, color: '#9ca3af' }}>{hint}</span>
</div>
))}
</div>
)}
{/* Test beep */}
<div style={{ padding: '14px 20px', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>Δοκιμαστικό Beep:</span>
{printers.length > 0 ? (
<>
<select
value={testPrinter ?? ''}
onChange={e => setTestPrinter(Number(e.target.value))}
disabled={testing}
style={{ ...inputStyle, width: 200 }}
>
{printers.map(p => <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>)}
</select>
<button
onClick={handleTestBeep}
disabled={testing || preset === 'off' || !testPrinter}
style={{
...btnSecondary,
opacity: (testing || preset === 'off') ? 0.5 : 1,
cursor: (testing || preset === 'off') ? 'default' : 'pointer',
}}
>
{testing ? 'Αποστολή…' : '🔔 Τεστ Beep'}
</button>
{preset === 'off' && (
<span style={{ fontSize: 12, color: '#9ca3af' }}>Ο ήχος είναι απενεργοποιημένος</span>
)}
</>
) : (
<span style={{ fontSize: 12, color: '#ef4444' }}>Δεν υπάρχουν εκτυπωτές</span>
)}
</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', 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 (
<div style={{
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 10,
padding: '16px 20px', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΟΝΟΜΑ</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Κουζίνα" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 130px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>IP ADDRESS</label>
<input value={form.ip_address} onChange={e => set('ip_address', e.target.value)}
placeholder="10.98.20.25" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 80px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
type="number" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΧΑΡΑΚΤ./ΓΡΑΜΜΗ</label>
<input value={form.line_width} onChange={e => set('line_width', parseInt(e.target.value) || 48)}
type="number" min={20} max={80} style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
style={btnPrimary}>Αποθήκευση</button>
<button onClick={onCancel} style={btnSecondary}>Άκυρο</button>
</div>
</div>
)
}
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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 20px', borderBottom: '1px solid #f4f4f2',
opacity: printer.is_active ? 1 : 0.5,
flexWrap: 'wrap',
}}>
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
style={{
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
background: printer.is_active ? '#16a34a' : '#d1d5db', position: 'relative', transition: 'background 150ms',
}}>
<span style={{
position: 'absolute', top: 3, left: printer.is_active ? 21 : 3,
width: 16, height: 16, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
<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 }}>
{printer.ip_address}:{printer.port}
</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.line_width ?? 48} χαρ.</span>
</div>
<span style={{
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
color: reachable === null ? '#9ca3af' : reachable ? '#16a34a' : '#dc2626',
}}>
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
</span>
<button onClick={() => onTest(printer.id)} disabled={testPending}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Test Print
</button>
<button onClick={() => onEdit(printer)}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Επεξεργασία
</button>
<button onClick={() => onDelete(printer.id)} style={{ ...btnDanger, flexShrink: 0 }}>
Διαγραφή
</button>
</div>
)
}
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 (
<div className="card divide-y divide-gray-100">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
<div>
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
<p className="text-xs text-gray-400 mt-0.5">Διαχείριση εκτυπωτών του συστήματος</p>
</div>
<button onClick={() => { setShowNew(v => !v); setEditingId(null) }} style={btnSecondary}>
+ Νέος εκτυπωτής
</button>
</div>
{showNew && (
<div style={{ padding: '12px 20px' }}>
<PrinterForm
onSave={form => createMut.mutate(form)}
onCancel={() => setShowNew(false)}
isPending={createMut.isPending}
/>
</div>
)}
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση</p>}
{!isLoading && printers.length === 0 && !showNew && (
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
Δεν υπάρχουν εκτυπωτές ακόμα.
</p>
)}
{printers.map(printer => (
editingId === printer.id ? (
<div key={printer.id} style={{ padding: '12px 20px', borderBottom: '1px solid #f4f4f2' }}>
<PrinterForm
initial={printer}
onSave={form => updateMut.mutate({ id: printer.id, ...form })}
onCancel={() => setEditingId(null)}
isPending={updateMut.isPending}
/>
</div>
) : (
<PrinterRow
key={printer.id}
printer={printer}
onEdit={p => { setEditingId(p.id); setShowNew(false) }}
onDelete={id => deleteMut.mutate(id)}
onTest={id => testMut.mutate(id)}
onToggle={handleToggle}
testPending={testMut.isPending}
/>
)
))}
</div>
)
}
// ── 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 <div style={{ padding: 40, textAlign: 'center', color: '#9ca3af', fontSize: 14 }}>Φόρτωση</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 1. Printers */}
<PrintersSection />
{/* 2. Ticket mode */}
<TicketModeSection
value={val('print.ticket_mode')}
onChange={handleChange}
isPending={updateMut.isPending}
printers={printers}
/>
{/* 3. Beep settings */}
<BeepSection
beepEnabled={val('print.beep_on_ticket')}
beepPattern={val('print.beep_pattern')}
onChange={handleChange}
isPending={updateMut.isPending}
printers={printers}
/>
{/* 4. Font sizes */}
<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_GROUPS.map(group => (
<div key={group.group}>
<SubgroupHeader label={group.group} />
{group.fields.map((field) => (
<React.Fragment key={field.key}>
<FontRow
field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
nested={group.fields.length > 1}
/>
{field.key === 'print.font_ingredient' && (
<DotStyleRow
value={val('print.dot_style')}
onChange={handleChange}
isPending={updateMut.isPending}
/>
)}
</React.Fragment>
))}
</div>
))}
</div>
{/* 5. Divider style */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
</div>
<DividerRow
value={val('print.divider_style')}
onChange={handleChange}
isPending={updateMut.isPending}
/>
</div>
<div style={{
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10,
padding: '12px 16px', fontSize: 12, color: '#92400e', lineHeight: 1.6,
}}>
<strong>Σημείωση:</strong> Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48.
Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
</div>
</div>
)
}