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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
383
manager_dashboard/src/pages/Settings/tabs/FiscalTab.jsx
Normal file
383
manager_dashboard/src/pages/Settings/tabs/FiscalTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user