feat: bump client-services (accumulated feature work + deploy fixes)

Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -437,7 +437,7 @@ export default function AppInfoTab() {
<DataTransferSection />
{user?.role === 'sysadmin' && (
{user?.role === 'superadmin' && (
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>

View File

@@ -6,18 +6,29 @@ import toast from 'react-hot-toast'
// ─── Colour slot metadata ────────────────────────────────────────────────────
const SLOTS = [
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
{ key: 'cardBg', key2: 'cardBg2', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', key2: 'badgeBg2', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', key2: 'nameText2', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', key2: 'badgeText2', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
]
function useFlashToggle(active, ms = 700) {
const [frame, setFrame] = useState(false)
useEffect(() => {
if (!active) { setFrame(false); return }
const id = setInterval(() => setFrame(f => !f), ms)
return () => clearInterval(id)
}, [active, ms])
return frame
}
const STATUSES = [
{ key: 'free', label: 'Ελεύθερο' },
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
{ key: 'paid', label: 'Πληρωμένο' },
{ key: 'kds_ready', label: 'Έτοιμο για Παράδοση (KDS)', hasFlash: true },
]
const STATUS_LABELS_MOCK = {
@@ -26,14 +37,19 @@ const STATUS_LABELS_MOCK = {
mine: 'ΔΙΚΟ ΜΟΥ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
paid: 'ΠΛΗΡΩΜΕΝΟ',
kds_ready: 'READY',
}
// Quick-suggest palettes per slot type
const QUICK_SWATCHES = {
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
cardBg2: ['rgba(0,0,0,0)', '#ffffff', '#dde5ef', '#243044', '#FF8F60', '#e8610a', '#22c55e', '#3b82f6'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
badgeBg2: ['rgba(0,0,0,0)', 'rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
nameText2: ['rgba(0,0,0,0)', '#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
badgeText2: ['rgba(0,0,0,0)', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0', '#22c55e', '#ffffff'],
}
// ─── Color picker modal ──────────────────────────────────────────────────────
@@ -106,7 +122,9 @@ function ColourPickerModal({ value, onClose, onChange, slot }) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
{SLOTS.find(s => s.key === slot)?.label || (SLOTS.find(s => s.key2 === slot)?.label + ' (χρώμα 2)')}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
</div>
@@ -259,34 +277,43 @@ function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
// ─── Mini mock table card (for preview) ──────────────────────────────────────
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ', flashFrame = false }) {
return (
<div style={{
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
width: '100%', height: 90, borderRadius: 12,
background: flashFrame ? (cfg.cardBg2 ?? cfg.cardBg) : cfg.cardBg,
position: 'relative', flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
overflow: 'hidden',
transition: 'background 0.15s',
}}>
{/* Table name + group */}
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{
fontSize: 17, fontWeight: 800, color: cfg.nameText,
fontSize: 17, fontWeight: 800,
color: flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText,
lineHeight: 1, letterSpacing: -0.5,
transition: 'color 0.15s',
}}>{mockName}</span>
<span style={{
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
color: cfg.nameText + '80',
color: (flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText) + '80',
textTransform: 'uppercase',
}}>{groupName}</span>
</div>
{/* Status badge — tight equal padding on all sides */}
{/* Status badge */}
<div style={{
position: 'absolute', bottom: 7, left: 7,
background: cfg.badgeBg,
background: flashFrame ? (cfg.badgeBg2 ?? cfg.badgeBg) : cfg.badgeBg,
borderRadius: 4, padding: '2px 5px',
lineHeight: 1,
lineHeight: 1, transition: 'background 0.15s',
}}>
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
<span style={{
fontSize: 7, fontWeight: 700,
color: flashFrame ? (cfg.badgeText2 ?? cfg.badgeText) : cfg.badgeText,
whiteSpace: 'nowrap', lineHeight: 1,
transition: 'color 0.15s',
}}>
{label}
</span>
</div>
@@ -336,30 +363,82 @@ function PreviewPanel({ colours, mode }) {
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
function StatusBlock({ mode, status, label, colours, onOpen }) {
function StatusBlock({ mode, status, label, hasFlash, colours, onOpen, onSetFlash }) {
const cfg = colours[mode][status]
const flashFrame = useFlashToggle(hasFlash && !!cfg.flash)
return (
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 88, flexShrink: 0 }}>
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" flashFrame={flashFrame} />
</div>
<div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
{hasFlash && (
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<div
onClick={() => onSetFlash(mode, !cfg.flash)}
style={{
width: 36, height: 20, borderRadius: 10,
background: cfg.flash ? '#22c55e' : '#d1d5db',
position: 'relative', cursor: 'pointer', transition: 'background 0.2s',
flexShrink: 0,
}}
>
<div style={{
position: 'absolute', top: 2, left: cfg.flash ? 18 : 2,
width: 16, height: 16, borderRadius: '50%',
background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
transition: 'left 0.2s',
}} />
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
Αναλαμπή (Flashing)
</span>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
{SLOTS.map(slot => (
<ColourSlotRow
key={slot.key}
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
<div key={slot.key}>
<ColourSlotRow
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
{hasFlash && cfg.flash && slot.key2 && (
<div style={{ paddingLeft: 20, marginTop: -4, marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 12, height: 12, borderRadius: 3, background: cfg[slot.key2] || 'rgba(0,0,0,0)', border: '1px solid #e5e7eb', flexShrink: 0 }} />
<button
onClick={() => onOpen(mode, status, slot.key2, cfg[slot.key2] || cfg[slot.key])}
style={{
width: 36, height: 22, borderRadius: 6,
background: cfg[slot.key2] || 'rgba(0,0,0,0)',
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
backgroundImage: !cfg[slot.key2] || cfg[slot.key2] === 'rgba(0,0,0,0)'
? 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)'
: 'none',
backgroundSize: '6px 6px',
backgroundPosition: '0 0,0 3px,3px -3px,-3px 0',
}}
/>
<div>
<div style={{ fontSize: 11, color: '#6b7280' }}>{slot.label} (χρώμα 2)</div>
<div style={{ fontSize: 10, color: '#9ca3af', fontFamily: 'monospace' }}>
{cfg[slot.key2] || 'διάφανο'}
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
@@ -368,7 +447,7 @@ function StatusBlock({ mode, status, label, colours, onOpen }) {
// ─── Mode section (light or dark) ────────────────────────────────────────────
function ModeSection({ mode, colours, onOpen }) {
function ModeSection({ mode, colours, onOpen, onSetFlash }) {
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
return (
<div>
@@ -380,8 +459,10 @@ function ModeSection({ mode, colours, onOpen }) {
mode={mode}
status={s.key}
label={s.label}
hasFlash={s.hasFlash}
colours={colours}
onOpen={onOpen}
onSetFlash={onSetFlash}
/>
))}
</div>
@@ -402,7 +483,17 @@ export default function ColoursTab() {
client.get('/api/settings/').then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) {
try { setColours(JSON.parse(raw)) } catch {}
try {
const loaded = JSON.parse(raw)
// Deep-merge with defaults so any missing statuses/slots are backfilled
const merged = { light: {}, dark: {} }
for (const mode of ['light', 'dark']) {
for (const [status, defaults] of Object.entries(DEFAULT_COLOURS[mode])) {
merged[mode][status] = { ...defaults, ...(loaded[mode]?.[status] ?? {}) }
}
}
setColours(merged)
} catch {}
}
})
}, [])
@@ -432,6 +523,20 @@ export default function ColoursTab() {
})
}
function setFlash(mode, value) {
setColours(prev => {
const next = {
...prev,
[mode]: {
...prev[mode],
kds_ready: { ...prev[mode].kds_ready, flash: value },
},
}
saveToBackend(next)
return next
})
}
function openModal(mode, status, slot, value) {
setModal({ mode, status, slot, value })
}
@@ -461,8 +566,8 @@ export default function ColoursTab() {
{/* Light + Dark mode settings */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
<ModeSection mode="light" colours={colours} onOpen={openModal} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
<ModeSection mode="light" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
</div>
</div>

View File

@@ -0,0 +1,383 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, disabled = false }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
style={{
width: 44, height: 24, borderRadius: 999, border: 'none',
cursor: disabled ? 'default' : 'pointer',
background: checked ? '#16a34a' : '#d1d5db',
position: 'relative', transition: 'background 150ms', flexShrink: 0,
opacity: disabled ? 0.5 : 1,
}}
>
<span style={{
position: 'absolute', top: 3, left: checked ? 23 : 3,
width: 18, height: 18, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)
}
function SectionTitle({ children }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3 mt-6 first:mt-0">
{children}
</h3>
)
}
function SettingRow({ label, hint, children }) {
return (
<div className="flex items-start justify-between gap-6 py-3 border-b border-slate-100 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700">{label}</p>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function FiscalTab() {
const queryClient = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const val = (key) => settings?.[key]?.value ?? ''
// Local state for folder paths (to show test result without saving first)
const [outFolder, setOutFolder] = useState('')
const [inFolder, setInFolder] = useState('')
const [testResult, setTestResult] = useState(null)
const [testing, setTesting] = useState(false)
// VAT groups state (parsed from JSON setting)
const [vatGroups, setVatGroups] = useState([])
// End message lines
const [endLines, setEndLines] = useState(['', '', '', '', ''])
useEffect(() => {
if (!settings) return
setOutFolder(val('fiscal.out_folder'))
setInFolder(val('fiscal.in_folder'))
try {
const groups = JSON.parse(val('fiscal.vat_groups') || '[]')
setVatGroups(Array.isArray(groups) ? groups : [])
} catch { setVatGroups([]) }
try {
const lines = JSON.parse(val('fiscal.end_message') || '[]')
const padded = [...lines, '', '', '', '', ''].slice(0, 5)
setEndLines(padded)
} catch { setEndLines(['', '', '', '', '']) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings])
const saveSetting = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pos-settings'] }),
})
function save(key, value) {
saveSetting.mutate({ key, value }, {
onError: () => toast.error(`Αποτυχία αποθήκευσης ${key}`),
})
}
function saveAndToast(key, value, label) {
saveSetting.mutate({ key, value }, {
onSuccess: () => toast.success(`${label} αποθηκεύτηκε`),
onError: () => toast.error('Αποτυχία αποθήκευσης'),
})
}
async function testFolders() {
setTesting(true)
setTestResult(null)
try {
const res = await client.post('/api/fiscal/test-folders', {
out_folder: outFolder,
in_folder: inFolder,
})
setTestResult(res.data)
} catch (e) {
setTestResult({ ok: false, out_folder_error: String(e), in_folder_error: null })
} finally {
setTesting(false)
}
}
function saveFolders() {
save('fiscal.out_folder', outFolder)
save('fiscal.in_folder', inFolder)
toast.success('Φάκελοι αποθηκεύτηκαν')
setTestResult(null)
}
// VAT group helpers
function addVatGroup() {
setVatGroups(prev => [...prev, { machine_id: '', friendly_name: '' }])
}
function removeVatGroup(i) {
setVatGroups(prev => prev.filter((_, idx) => idx !== i))
}
function updateVatGroup(i, field, value) {
setVatGroups(prev => prev.map((g, idx) => idx === i ? { ...g, [field]: value } : g))
}
function saveVatGroups() {
const cleaned = vatGroups
.filter(g => String(g.machine_id).trim() && g.friendly_name.trim())
.map(g => ({ machine_id: parseInt(g.machine_id, 10), friendly_name: g.friendly_name.trim() }))
saveAndToast('fiscal.vat_groups', JSON.stringify(cleaned), 'Ομάδες ΦΠΑ')
}
// End message helpers
function updateEndLine(i, value) {
setEndLines(prev => prev.map((l, idx) => idx === i ? value : l))
}
function saveEndMessage() {
const cleaned = endLines.map(l => l.trim())
saveAndToast('fiscal.end_message', JSON.stringify(cleaned), 'Μήνυμα τέλους')
}
if (isLoading) return <div className="text-sm text-slate-400 py-8 text-center">Φόρτωση</div>
const fiscalEnabled = val('fiscal.enabled') === 'true'
return (
<div className="max-w-xl space-y-1">
{/* Master switch */}
<div className="card px-5 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-slate-800">Φορολογική Εκτύπωση (ΦΗΜ)</p>
<p className="text-xs text-slate-400 mt-0.5">
Ενεργοποιεί αυτόματη αποστολή αποδείξεων στη Φορολογική Μηχανή κατά την πληρωμή
</p>
</div>
<Toggle
checked={fiscalEnabled}
onChange={v => save('fiscal.enabled', v ? 'true' : 'false')}
/>
</div>
{fiscalEnabled && (
<div className="mt-3 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-800">
Η ΦΗΜ είναι <strong>ενεργή</strong>. Κάθε πληρωμή στο PWA θα αποστέλλει αυτόματα απόδειξη.
Βεβαιωθείτε ότι οι φάκελοι και οι ομάδες ΦΠΑ είναι σωστά ρυθμισμένα.
</div>
)}
</div>
{/* Driver type */}
<div className="card px-5 divide-y divide-slate-100">
<SectionTitle>Τύπος Προγράμματος Οδήγησης</SectionTitle>
<SettingRow
label="Τύπος ΦΗΜ"
hint="Επί του παρόντος υποστηρίζεται μόνο TXT File (dTEC100extra)"
>
<select
value={val('fiscal.type')}
onChange={e => save('fiscal.type', e.target.value)}
className="input text-sm h-9 pr-8"
>
<option value="txt_file">TXT File (dTEC100extra)</option>
</select>
</SettingRow>
</div>
{/* Folder paths */}
<div className="card px-5 py-4 space-y-3">
<SectionTitle>Φάκελοι Επικοινωνίας</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
Οι φάκελοι πρέπει να είναι προσβάσιμοι μέσα από το Docker container (mounted volume ή UNC path).
<br />
<span className="font-medium">OUT folder</span> = εκεί γράφει το Xenia (ο driver τα διαβάζει ως IN).
<br />
<span className="font-medium">IN folder</span> = εκεί γράφει ο driver τις απαντήσεις (το Xenia τις διαβάζει).
</p>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-slate-600 block mb-1">OUT Folder (Xenia ΦΗΜ driver)</label>
<input
className="input w-full text-sm font-mono"
placeholder="/mnt/fiscal/in"
value={outFolder}
onChange={e => setOutFolder(e.target.value)}
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 block mb-1">IN Folder (ΦΗΜ driver Xenia)</label>
<input
className="input w-full text-sm font-mono"
placeholder="/mnt/fiscal/out"
value={inFolder}
onChange={e => setInFolder(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={testFolders}
disabled={testing || !outFolder || !inFolder}
className="btn btn-secondary text-sm px-4"
>
{testing ? 'Έλεγχος…' : 'Έλεγχος σύνδεσης'}
</button>
<button
onClick={saveFolders}
disabled={!outFolder || !inFolder}
className="btn btn-primary text-sm px-4"
>
Αποθήκευση
</button>
</div>
{testResult && (
<div className={`rounded-lg border px-3 py-2 text-xs font-mono ${
testResult.ok
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800'
}`}>
{testResult.ok ? (
<span> Και οι δύο φάκελοι είναι προσβάσιμοι</span>
) : (
<div className="space-y-1">
{testResult.out_folder_error && <div>OUT: {testResult.out_folder_error}</div>}
{testResult.in_folder_error && <div>IN: {testResult.in_folder_error}</div>}
</div>
)}
</div>
)}
</div>
{/* Clerk / EFTPOS IDs */}
<div className="card px-5 divide-y divide-slate-100">
<SectionTitle>Ρυθμίσεις Εντολών</SectionTitle>
<SettingRow
label="Clerk ID"
hint="Κωδικός ταμία για εντολές CR / CD"
>
<input
type="number" min="1"
className="input w-20 text-sm text-center"
value={val('fiscal.clerk_id')}
onChange={e => save('fiscal.clerk_id', e.target.value)}
/>
</SettingRow>
<SettingRow
label="EFTPOS ID"
hint="Αριθμός τερματικού EFTPOS για εντολή CD (πληρωμή με κάρτα)"
>
<input
type="number" min="1"
className="input w-20 text-sm text-center"
value={val('fiscal.eftpos_id')}
onChange={e => save('fiscal.eftpos_id', e.target.value)}
/>
</SettingRow>
</div>
{/* End message */}
<div className="card px-5 py-4 space-y-3">
<SectionTitle>Μήνυμα Τέλους Απόδειξης (FM)</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
Μέχρι 5 γραμμές κειμένου που εκτυπώνονται στο τέλος κάθε απόδειξης.
Αφήστε κενές τις γραμμές που δεν θέλετε να χρησιμοποιήσετε.
</p>
{endLines.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-4 text-right shrink-0">{i + 1}</span>
<input
className="input flex-1 text-sm font-mono uppercase"
placeholder={`Γραμμή ${i + 1}`}
value={line}
maxLength={40}
onChange={e => updateEndLine(i, e.target.value)}
/>
</div>
))}
<button onClick={saveEndMessage} className="btn btn-primary text-sm px-4">
Αποθήκευση μηνύματος
</button>
</div>
{/* VAT Groups */}
<div className="card px-5 py-4 space-y-3">
<SectionTitle>Ομάδες ΦΠΑ ΦΗΜ</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
Ορίστε τις ομάδες ΦΠΑ όπως είναι προγραμματισμένες στη ΦΗΜ.
Το <strong>ID ΦΗΜ</strong> είναι ο αριθμός που χρησιμοποιεί η μηχανή (π.χ. 1, 2, 3).
Θα χρησιμοποιηθούν κατά την ανάθεση σε προϊόντα.
</p>
{vatGroups.length === 0 && (
<p className="text-xs text-slate-400 italic">Δεν έχουν οριστεί ομάδες ΦΠΑ ακόμα.</p>
)}
<div className="space-y-2">
{vatGroups.map((g, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-slate-400 font-medium">ID ΦΗΜ</span>
<input
type="number" min="1"
className="input w-16 text-sm text-center"
placeholder="1"
value={g.machine_id}
onChange={e => updateVatGroup(i, 'machine_id', e.target.value)}
/>
</div>
<div className="flex flex-col flex-1 gap-0.5">
<span className="text-[10px] text-slate-400 font-medium">Φιλικό όνομα</span>
<input
className="input text-sm"
placeholder="π.χ. Νερό, Καφές, Αλκοόλ…"
value={g.friendly_name}
onChange={e => updateVatGroup(i, 'friendly_name', e.target.value)}
/>
</div>
<button
onClick={() => removeVatGroup(i)}
className="btn btn-danger text-xs px-2 py-1.5 min-h-0 h-8 mt-4 shrink-0"
>
</button>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button onClick={addVatGroup} className="btn btn-secondary text-sm px-3">
+ Προσθήκη ομάδας
</button>
{vatGroups.length > 0 && (
<button onClick={saveVatGroups} className="btn btn-primary text-sm px-4">
Αποθήκευση ομάδων
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
@@ -63,11 +63,14 @@ function ShiftSettingsSection() {
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
const hideRevenue = settings?.['shifts.hide_revenue_from_waiters']?.value ?? 'false'
const revertAllowed = settings?.['payments.waiter_revert_allowed']?.value ?? 'false'
const priceAdjAllowed = settings?.['orders.waiter_price_adjust_allowed']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
<SectionCard title="Ρυθμίσεις Προσωπικού" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<>
@@ -83,6 +86,320 @@ function ShiftSettingsSection() {
>
<Toggle checked={cancelAllowed === 'true'} onChange={() => toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Απόκρυψη Εισπράξεων από Σερβιτόρους"
description="Όταν είναι ΕΝΕΡΓΌ, οι σερβιτόροι δεν βλέπουν το ποσό είσπραξης και το ποσό παράδοσης στην Επισκόπηση Βάρδιας — αποτρέπει παραποίηση των ταμειακών."
>
<Toggle checked={hideRevenue === 'true'} onChange={() => toggle('shifts.hide_revenue_from_waiters', hideRevenue)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Αναίρεση Πληρωμής από Σερβιτόρους"
description="Επιτρέπει στους σερβιτόρους να αναιρούν μεμονωμένες πληρωμένες χρεώσεις από την καρτέλα ΠΛΗΡΩΜΕΝΑ, επαναφέροντάς τες σε μη πληρωμένες. Χρήσιμο για διόρθωση λαθών χωρίς παρέμβαση διαχειριστή."
>
<Toggle checked={revertAllowed === 'true'} onChange={() => toggle('payments.waiter_revert_allowed', revertAllowed)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Προσαρμογή Τιμής από Σερβιτόρους"
description="Επιτρέπει στους σερβιτόρους να αλλάζουν την τιμή ενός αντικειμένου κατά την παραγγελία ή μετά. Αν είναι ΚΛΕΙΣΤΌ, κανένας σερβιτόρος δεν μπορεί να τροποποιήσει τιμή — ανεξάρτητα από τις ατομικές ρυθμίσεις."
>
<Toggle checked={priceAdjAllowed === 'true'} onChange={() => toggle('orders.waiter_price_adjust_allowed', priceAdjAllowed)} disabled={updateMut.isPending} />
</OptionRow>
</>
)}
</SectionCard>
)
}
function PaymentSettingsSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_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 toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const cardEnabled = settings?.['payments.card_enabled']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Πληρωμών" description="Μέθοδοι πληρωμής που είναι διαθέσιμες στους σερβιτόρους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<OptionRow
label="Πληρωμή με Κάρτα"
description="Επιτρέπει στους σερβιτόρους να καταχωρούν πληρωμές με κάρτα. Το σύστημα δεν επικοινωνεί με POS μηχάνημα — η επιλογή καταγράφεται μόνο για αναφορά."
>
<Toggle checked={cardEnabled === 'true'} onChange={() => toggle('payments.card_enabled', cardEnabled)} disabled={updateMut.isPending} />
</OptionRow>
)}
</SectionCard>
)
}
const COURSE_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#06b6d4','#64748b','#10b981']
function CoursesSection({ settings, updateMut }) {
const coursesEnabled = settings?.['orders.courses_enabled']?.value === 'true'
const [courses, setCourses] = useState(() => {
try { return JSON.parse(settings?.['orders.courses']?.value || '[]') }
catch { return [] }
})
// Sync courses from settings when settings change (e.g. after save)
const settingsCoursesStr = settings?.['orders.courses']?.value
useEffect(() => {
try { setCourses(JSON.parse(settingsCoursesStr || '[]')) }
catch { setCourses([]) }
}, [settingsCoursesStr])
const dragItem = useRef(null)
const dragOverItem = useRef(null)
function saveCourses(next) {
setCourses(next)
updateMut.mutate({ key: 'orders.courses', value: JSON.stringify(next) })
}
function addCourse() {
const newId = Math.max(...courses.map(c => c.id), 0) + 1
const colorIdx = courses.length % COURSE_COLORS.length
saveCourses([...courses, { id: newId, name: `Course ${newId}`, color: COURSE_COLORS[colorIdx] }])
}
function deleteCourse(id) {
saveCourses(courses.filter(c => c.id !== id))
}
function updateName(id, name) {
saveCourses(courses.map(c => c.id === id ? { ...c, name } : c))
}
function cycleColor(id) {
saveCourses(courses.map(c => {
if (c.id !== id) return c
const idx = COURSE_COLORS.indexOf(c.color)
const next = COURSE_COLORS[(idx + 1) % COURSE_COLORS.length]
return { ...c, color: next }
}))
}
function handleDragStart(e, idx) {
dragItem.current = idx
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e, idx) {
e.preventDefault()
dragOverItem.current = idx
}
function handleDrop() {
if (dragItem.current === null || dragOverItem.current === null) return
if (dragItem.current === dragOverItem.current) return
const next = [...courses]
const dragged = next.splice(dragItem.current, 1)[0]
next.splice(dragOverItem.current, 0, dragged)
dragItem.current = null
dragOverItem.current = null
saveCourses(next)
}
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 20px', borderBottom: '1px solid #f4f4f2' }
return (
<div>
{coursesEnabled && (
<div>
{courses.map((course, idx) => (
<div
key={course.id}
draggable
onDragStart={e => handleDragStart(e, idx)}
onDragOver={e => handleDragOver(e, idx)}
onDrop={handleDrop}
style={{ ...rowStyle, cursor: 'grab' }}
>
{/* Drag handle */}
<span style={{ color: '#9ca3af', fontSize: 14, flexShrink: 0, cursor: 'grab' }}></span>
{/* Color swatch */}
<button
onClick={() => cycleColor(course.id)}
title="Click to change color"
style={{
width: 20, height: 20, borderRadius: '50%',
background: course.color,
border: '2px solid rgba(0,0,0,0.12)',
cursor: 'pointer', flexShrink: 0,
padding: 0,
}}
/>
{/* Name input */}
<input
value={course.name}
onChange={e => setCourses(cs => cs.map(c => c.id === course.id ? { ...c, name: e.target.value } : c))}
onBlur={e => updateName(course.id, e.target.value)}
style={{
flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6',
padding: '0 10px', fontSize: 13, fontFamily: 'inherit',
}}
/>
{/* Delete */}
<button
onClick={() => deleteCourse(course.id)}
style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1px solid #fee2e2', background: '#fff5f5',
fontSize: 12, cursor: 'pointer', color: '#dc2626', flexShrink: 0,
}}
>Διαγραφή</button>
</div>
))}
<div style={{ padding: '10px 20px' }}>
<button
onClick={addCourse}
style={{
height: 32, padding: '0 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}
>+ Add course</button>
</div>
</div>
)}
</div>
)
}
const DEFAULT_QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
function QuickNotesSection({ settings, updateMut }) {
const raw = settings?.['orders.quick_notes']?.value
const [notes, setNotes] = useState(() => {
try { return JSON.parse(raw || 'null') ?? DEFAULT_QUICK_NOTES }
catch { return DEFAULT_QUICK_NOTES }
})
const [newNote, setNewNote] = useState('')
const settingsRaw = settings?.['orders.quick_notes']?.value
useEffect(() => {
try { setNotes(JSON.parse(settingsRaw || 'null') ?? DEFAULT_QUICK_NOTES) }
catch { setNotes(DEFAULT_QUICK_NOTES) }
}, [settingsRaw])
function saveNotes(next) {
setNotes(next)
updateMut.mutate({ key: 'orders.quick_notes', value: JSON.stringify(next) })
}
function updateNote(idx, val) {
const next = notes.map((n, i) => i === idx ? val : n)
setNotes(next)
}
function commitNote(idx) {
saveNotes(notes.map((n, i) => i === idx ? n.trim() : n).filter(n => n.length > 0))
}
function deleteNote(idx) {
saveNotes(notes.filter((_, i) => i !== idx))
}
function addNote() {
const trimmed = newNote.trim()
if (!trimmed) return
setNewNote('')
saveNotes([...notes, trimmed])
}
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 20px', borderBottom: '1px solid #f4f4f2' }
return (
<div>
<div style={{ padding: '12px 20px 4px', fontSize: 12, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Quick Note Options
</div>
{notes.map((note, idx) => (
<div key={idx} style={rowStyle}>
<input
value={note}
onChange={e => updateNote(idx, e.target.value)}
onBlur={() => commitNote(idx)}
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }}
/>
<button
onClick={() => deleteNote(idx)}
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626', flexShrink: 0 }}
>Διαγραφή</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, padding: '10px 20px' }}>
<input
value={newNote}
onChange={e => setNewNote(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addNote()}
placeholder="Νέα γρήγορη σημείωση…"
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }}
/>
<button
onClick={addNote}
style={{ height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151' }}
>+ Προσθήκη</button>
</div>
</div>
)
}
function TableOrderSettingsSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_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 toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const autoClose = settings?.['orders.auto_close_on_full_payment']?.value ?? 'false'
const bypassKds = settings?.['orders.bypass_kds_serve']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Τραπεζιών και Παραγγελιών" description="Συμπεριφορά τραπεζιών και παραγγελιών">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<>
<OptionRow
label="Αυτόματο Κλείσιμο Τραπεζιού μετά την πλήρη πληρωμή"
description="Το τραπέζι κλείνει αυτόματα όταν πληρωθούν όλα τα αντικείμενα"
>
<Toggle checked={autoClose === 'true'} onChange={() => toggle('orders.auto_close_on_full_payment', autoClose)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Παράκαμψη KDS και Κατάστασης Σερβιρίσματος"
description="Οι νέες παραγγελίες σημειώνονται άμεσα ως σερβιρισμένες — δεν απαιτείται επιβεβαίωση από σερβιτόρο"
>
<Toggle checked={bypassKds === 'true'} onChange={() => toggle('orders.bypass_kds_serve', bypassKds)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Courses"
description="Enable course-based ordering — assign items to courses (Starter, Main, Dessert, etc.) for sequenced firing"
>
<Toggle
checked={settings?.['orders.courses_enabled']?.value === 'true'}
onChange={() => toggle('orders.courses_enabled', settings?.['orders.courses_enabled']?.value ?? 'false')}
disabled={updateMut.isPending}
/>
</OptionRow>
<CoursesSection settings={settings} updateMut={updateMut} />
<QuickNotesSection settings={settings} updateMut={updateMut} />
</>
)}
</SectionCard>
@@ -268,21 +585,21 @@ function QuickTemplatesSection() {
const [showNew, setShowNew] = useState(false)
const { data: templates = [], isLoading } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
queryFn: () => client.get('/api/notifications/templates').then(r => r.data),
staleTime: 30_000,
})
const createMut = useMutation({
mutationFn: (body) => client.post('/api/messages/templates', body),
mutationFn: (body) => client.post('/api/notifications/templates', body),
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setShowNew(false); setNewBody('') },
onError: () => toast.error('Σφάλμα'),
})
const updateMut = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/messages/templates/${id}`, { body }),
mutationFn: ({ id, body }) => client.put(`/api/notifications/templates/${id}`, { body }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setEditingId(null) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteMut = useMutation({
mutationFn: (id) => client.delete(`/api/messages/templates/${id}`),
mutationFn: (id) => client.delete(`/api/notifications/templates/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }) },
onError: () => toast.error('Σφάλμα'),
})
@@ -334,10 +651,66 @@ function QuickTemplatesSection() {
)
}
// ─── Auto schedule settings ───────────────────────────────────────────────────
const SCHED_STORAGE_KEY = 'xenia_schedule_settings_v1'
const SCHED_DEFAULTS = {
autoStartWorkday: false,
autoCloseWorkday: false,
autoCloseShiftsOnWorkday: false,
autoStartStaffShift: false,
autoCloseStaffShift: false,
}
function loadSchedSettings() {
try { return { ...SCHED_DEFAULTS, ...JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}') } }
catch { return { ...SCHED_DEFAULTS } }
}
function AutoScheduleSection() {
const [s, setS] = useState(loadSchedSettings)
function toggle(key) {
setS(prev => {
const next = { ...prev, [key]: !prev[key] }
const stored = JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}')
localStorage.setItem(SCHED_STORAGE_KEY, JSON.stringify({ ...stored, ...next }))
return next
})
}
return (
<SectionCard title="Αυτόματο Πρόγραμμα Καταστήματος" description="Ρυθμίσεις αυτόματης έναρξης/κλεισίματος εργάσιμης και βαρδιών">
<OptionRow label="Αυτόματη Έναρξη Εργάσιμης" description="Η εργάσιμη ξεκινά αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoStartWorkday} onChange={() => toggle('autoStartWorkday')} />
</OptionRow>
<OptionRow label="Αυτόματο Κλείσιμο Εργάσιμης" description="Η εργάσιμη κλείνει αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoCloseWorkday} onChange={() => toggle('autoCloseWorkday')} />
</OptionRow>
{s.autoCloseWorkday && (
<div className="pl-8">
<OptionRow label="Κλείσιμο βαρδιών προσωπικού" description="Κλείνουν αυτόματα αν δεν υπάρχουν ανοιχτές παραγγελίες">
<Toggle checked={s.autoCloseShiftsOnWorkday} onChange={() => toggle('autoCloseShiftsOnWorkday')} />
</OptionRow>
</div>
)}
<OptionRow label="Αυτόματη Έναρξη Βάρδιας Προσωπικού" description="Οι βάρδιες ξεκινούν αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoStartStaffShift} onChange={() => toggle('autoStartStaffShift')} />
</OptionRow>
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας Προσωπικού" description="Οι βάρδιες κλείνουν αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoCloseStaffShift} onChange={() => toggle('autoCloseStaffShift')} />
</OptionRow>
</SectionCard>
)
}
export default function OperationTab() {
return (
<div className="space-y-6">
<ShiftSettingsSection />
<PaymentSettingsSection />
<TableOrderSettingsSection />
<AutoScheduleSection />
<FlagDefsSection />
<QuickTemplatesSection />
</div>

View File

@@ -637,7 +637,15 @@ function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }
// ── 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, duplicates: 0 }
const PRINTER_PROFILES = [
{ label: 'Jolimark TP850UE', codepage_n: 29 },
{ label: 'S4MAS Giant 100', codepage_n: 29 },
{ label: 'NETUM NT8330L', codepage_n: 24 },
]
const codepageToProfile = (n) => PRINTER_PROFILES.find(p => p.codepage_n === n) ?? PRINTER_PROFILES[0]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, codepage_n: 29, is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
@@ -663,10 +671,16 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
<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>)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 180px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΜΟΝΤΕΛΟ ΕΚΤΥΠΩΤΗ</label>
<select
value={form.codepage_n}
onChange={e => set('codepage_n', parseInt(e.target.value))}
style={inputStyle}
>
{PRINTER_PROFILES.map(p => (
<option key={p.label} value={p.codepage_n}>{p.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
@@ -674,11 +688,6 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
<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', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΑΝΤΙΓΡΑΦΑ</label>
<input value={form.duplicates ?? 0} onChange={e => set('duplicates', Math.max(0, Math.min(9, parseInt(e.target.value) || 0)))}
type="number" min={0} max={9} style={inputStyle} title="0 = μία εκτύπωση, 1 = δύο κ.ο.κ." />
</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>
@@ -742,13 +751,8 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
<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 }}> {codepageToProfile(printer.codepage_n ?? 29).label}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.line_width ?? 48} χαρ.</span>
{(printer.duplicates ?? 0) > 0 && (
<span style={{ fontSize: 11, color: '#f59e0b', fontWeight: 700, marginLeft: 6 }}>
x{(printer.duplicates ?? 0) + 1} αντ.
</span>
)}
</div>
<span style={{

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Shield, Lock, LogOut, User, KeyRound } from 'lucide-react'
import { Shield, Lock, LogOut, User, KeyRound, ShieldCheck, Copy, RefreshCw, X } from 'lucide-react'
import client from '../../../api/client'
import { PANEL_CLASS } from '../../../ui/tokens'
import Button from '../../../ui/Button'
@@ -114,6 +115,143 @@ function TimeSelect({ value, onChange, disabled }) {
)
}
// ─── Recovery Codes section ───────────────────────────────────────────────────
function RecoveryCodesSection() {
const [revealedCodes, setRevealedCodes] = useState(null)
const [confirming, setConfirming] = useState(false)
const { data: statusData, refetch: refetchStatus } = useQuery({
queryKey: ['recovery-code-status'],
queryFn: () => client.get('/api/recovery/status').then(r => r.data),
})
const generateMut = useMutation({
mutationFn: () => client.post('/api/recovery/generate').then(r => r.data),
onSuccess: (data) => {
setRevealedCodes(data.codes)
setConfirming(false)
refetchStatus()
},
onError: () => toast.error('Σφάλμα δημιουργίας κωδικών'),
})
const unused = statusData?.unused_count ?? null
function copyAll() {
if (!revealedCodes) return
navigator.clipboard.writeText(revealedCodes.join('\n'))
toast.success('Οι κωδικοί αντιγράφηκαν!')
}
return (
<SectionCard
icon={ShieldCheck}
title="Κωδικοί Ανάκτησης"
description="Μονόχρηστοι κωδικοί έκτακτης ανάγκης. Κάθε κωδικός χρησιμοποιείται μία φορά και καίγεται."
>
<div className="px-5 py-4 space-y-4">
{/* Status badge */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700">Διαθέσιμοι κωδικοί:</span>
{unused === null ? (
<span className="text-xs text-gray-400">Φόρτωση</span>
) : (
<span className={`text-sm font-bold ${
unused === 0 ? 'text-rose-600' : unused <= 2 ? 'text-amber-600' : 'text-emerald-600'
}`}>
{unused} / 5
</span>
)}
</div>
{unused === 0 && (
<span className="text-[11px] bg-rose-50 text-rose-600 border border-rose-200 rounded-full px-2 py-0.5 font-medium">
Δεν υπάρχουν κωδικοί!
</span>
)}
{unused > 0 && unused <= 2 && (
<span className="text-[11px] bg-amber-50 text-amber-600 border border-amber-200 rounded-full px-2 py-0.5 font-medium">
Λίγοι κωδικοί
</span>
)}
</div>
{/* Revealed codes (shown once after generation) */}
{revealedCodes && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[12px] font-semibold text-emerald-800">
Αντιγράψτε τώρα δεν θα εμφανιστούν ξανά!
</p>
<button onClick={() => setRevealedCodes(null)} className="text-emerald-500 hover:text-emerald-700">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-1.5">
{revealedCodes.map((code, i) => (
<div key={i} className="flex items-center justify-between rounded-lg bg-white border border-emerald-200 px-3 py-2">
<span className="font-mono text-sm font-semibold text-emerald-900 tracking-wider">{code}</span>
<button
onClick={() => { navigator.clipboard.writeText(code); toast.success('Αντιγράφηκε') }}
className="text-emerald-400 hover:text-emerald-600"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
<Button variant="secondary" className="w-full justify-center gap-2 text-[13px]" onClick={copyAll}>
<Copy className="h-3.5 w-3.5" /> Αντιγραφή όλων
</Button>
</div>
)}
{/* Generate / confirm */}
{!revealedCodes && (
confirming ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-3">
<p className="text-[13px] text-amber-800">
Αυτό θα <strong>ακυρώσει</strong> τους {unused} υπάρχοντες κωδικούς και θα δημιουργήσει 5 νέους.
</p>
<div className="flex gap-2">
<Button
variant="danger"
className="flex-1 justify-center text-[13px]"
onClick={() => generateMut.mutate()}
disabled={generateMut.isPending}
>
{generateMut.isPending ? 'Δημιουργία…' : 'Ναι, δημιούργησε νέους'}
</Button>
<Button variant="secondary" className="flex-1 justify-center text-[13px]" onClick={() => setConfirming(false)}>
Άκυρο
</Button>
</div>
</div>
) : (
<Button
variant={unused === 0 ? 'primary' : 'secondary'}
className="w-full justify-center gap-2 text-[13px]"
onClick={() => unused > 0 ? setConfirming(true) : generateMut.mutate()}
disabled={generateMut.isPending}
>
<RefreshCw className="h-3.5 w-3.5" />
{unused === 0 ? 'Δημιουργία κωδικών ανάκτησης' : 'Αναδημιουργία κωδικών'}
</Button>
)
)}
<p className="text-[11px] text-gray-400">
Αποθηκεύστε τους σε ασφαλές μέρος. Κάθε κωδικός δουλεύει μόνο μία φορά.
Χρησιμοποιείται στη σελίδα σύνδεσης με το όνομα χρήστη + κωδικό ανάκτησης.
</p>
</div>
</SectionCard>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function SecurityTab() {
@@ -265,6 +403,9 @@ export default function SecurityTab() {
)}
</SectionCard>
{/* ── Κωδικοί Ανάκτησης ────────────────────────────────────────────── */}
<RecoveryCodesSection />
</div>
)
}