feat: printer beep support for kitchen tickets

Adds configurable buzzer control so the printer alerts kitchen staff
on each new ticket print.

Backend:
  - printer_service.py: ESC 0x07 beep command fired before paper cut,
    reads print.beep_on_ticket / print.beep_pattern settings; supports
    single/double/triple/long presets and a custom:n1:n2:n3 format
  - settings.py: registers the two new print.beep_* settings with
    defaults (beep_on_ticket=true, beep_pattern=double)
  - system.py: POST /api/system/printers/test-beep endpoint for
    live testing from the dashboard

Frontend (PrintFontsTab):
  - BeepSection component with preset grid, custom n1/n2/n3 inputs,
    and a live test-beep button targeting any configured printer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 23:53:58 +03:00
parent b9d6055c48
commit 607e78ea82
4 changed files with 254 additions and 2 deletions

View File

@@ -42,6 +42,8 @@ const FONT_DEFAULTS = {
'print.font_order_note': '0:1:0',
'print.divider_style': 'dash',
'print.ticket_mode': 'detailed',
'print.beep_on_ticket': 'true',
'print.beep_pattern': 'double',
}
// ── Preview ────────────────────────────────────────────────────────────────
@@ -347,6 +349,189 @@ function TicketModeSection({ value, onChange, isPending, printers }) {
)
}
// ── 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)' }]
@@ -638,7 +823,16 @@ export default function PrintFontsTab() {
printers={printers}
/>
{/* 3. Font sizes — grouped */}
{/* 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>
@@ -664,7 +858,7 @@ export default function PrintFontsTab() {
))}
</div>
{/* 4. Divider style */}
{/* 5. Divider style */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>