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>
999 lines
41 KiB
JavaScript
999 lines
41 KiB
JavaScript
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>
|
||
)
|
||
}
|