555 lines
30 KiB
JavaScript
555 lines
30 KiB
JavaScript
import { useState } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import toast from 'react-hot-toast'
|
||
import client from '../../../api/client'
|
||
import useAuthStore from '../../../store/authStore'
|
||
|
||
function Toggle({ checked, onChange, disabled }) {
|
||
return (
|
||
<button
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => !disabled && onChange(!checked)}
|
||
style={{
|
||
width: 44, height: 24, borderRadius: 999, border: 'none', cursor: disabled ? 'not-allowed' : '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>
|
||
)
|
||
}
|
||
|
||
const COMMON_TIMEZONES = [
|
||
'Europe/Athens', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Rome',
|
||
'Europe/Madrid', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Bucharest',
|
||
'Europe/Helsinki', 'Europe/Istanbul', 'America/New_York', 'America/Chicago',
|
||
'America/Denver', 'America/Los_Angeles', 'UTC',
|
||
]
|
||
|
||
function TimezoneSection() {
|
||
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('Σφάλμα αποθήκευσης'),
|
||
})
|
||
const currentTz = settings?.['system.timezone']?.value ?? 'Europe/Athens'
|
||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||
return (
|
||
<div className="card divide-y divide-gray-100">
|
||
<div className="px-5 py-4">
|
||
<h2 className="font-semibold text-gray-700">Ζώνη Ώρας</h2>
|
||
<p className="text-xs text-gray-400 mt-0.5">
|
||
Η ζώνη ώρας που χρησιμοποιεί το backend για χρονοσφραγίδες. Αν οι ώρες έναρξης βάρδιας εμφανίζονται λανθασμένες, ρυθμίστε αυτό να ταιριάζει με την τοπική σας ζώνη.
|
||
</p>
|
||
</div>
|
||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||
{!isLoading && (
|
||
<div className="px-5 py-4 space-y-3">
|
||
<div className="flex items-center gap-3">
|
||
<select
|
||
value={currentTz}
|
||
onChange={e => updateMut.mutate({ key: 'system.timezone', value: e.target.value })}
|
||
disabled={updateMut.isPending}
|
||
className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none flex-1 max-w-xs"
|
||
>
|
||
{COMMON_TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||
</select>
|
||
{updateMut.isPending && <span className="text-xs text-gray-400">Αποθήκευση…</span>}
|
||
</div>
|
||
<p className="text-xs text-gray-400">
|
||
Ζώνη ώρας browser: <span className="font-medium text-gray-600">{browserTz}</span>
|
||
{browserTz !== currentTz && (
|
||
<span className="ml-2 text-amber-600 font-medium">⚠ Διαφέρει από τη ρύθμιση backend</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||
Η αλλαγή ζώνης ώρας αποθηκεύεται και εφαρμόζεται στο frontend αμέσως. Για πλήρη εφαρμογή στον backend server (χρονοσφραγίδες), απαιτείται επανεκκίνηση του container.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const LOCK_TIMEOUT_OPTIONS = [
|
||
{ label: 'Απενεργοποιημένο', value: 0 },
|
||
{ label: '1 λεπτό', value: 1 },
|
||
{ label: '5 λεπτά', value: 5 },
|
||
{ label: '10 λεπτά', value: 10 },
|
||
{ label: '15 λεπτά', value: 15 },
|
||
{ label: '30 λεπτά', value: 30 },
|
||
{ label: '60 λεπτά', value: 60 },
|
||
]
|
||
|
||
const LOCK_SETTINGS_KEY = 'manager_lock_timeout'
|
||
|
||
function AutoLockSection() {
|
||
const raw = parseInt(localStorage.getItem(LOCK_SETTINGS_KEY) || '0', 10)
|
||
const [timeout, setTimeout_] = useState(isNaN(raw) ? 0 : raw)
|
||
|
||
function handleChange(val) {
|
||
const n = parseInt(val, 10)
|
||
setTimeout_(n)
|
||
if (n > 0) {
|
||
localStorage.setItem(LOCK_SETTINGS_KEY, String(n))
|
||
} else {
|
||
localStorage.removeItem(LOCK_SETTINGS_KEY)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card divide-y divide-gray-100">
|
||
<div className="px-5 py-4">
|
||
<h2 className="font-semibold text-gray-700">Αυτόματο Κλείδωμα Διαχειριστή</h2>
|
||
<p className="text-xs text-gray-400 mt-0.5">
|
||
Αν δεν υπάρξει δραστηριότητα για το παρακάτω διάστημα, η οθόνη κλειδώνει και ζητάει PIN.
|
||
Το 0 απενεργοποιεί το αυτόματο κλείδωμα.
|
||
</p>
|
||
</div>
|
||
<div className="px-5 py-4 flex items-center gap-4">
|
||
<select
|
||
value={timeout}
|
||
onChange={e => handleChange(e.target.value)}
|
||
className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none w-52"
|
||
>
|
||
{LOCK_TIMEOUT_OPTIONS.map(o => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
{timeout > 0 && (
|
||
<span className="text-xs text-green-700 font-medium bg-green-50 border border-green-200 rounded-lg px-3 py-1.5">
|
||
Κλείδωμα μετά από {timeout} {timeout === 1 ? 'λεπτό' : 'λεπτά'} αδράνειας
|
||
</span>
|
||
)}
|
||
{timeout === 0 && (
|
||
<span className="text-xs text-gray-500">Μόνο χειροκίνητο κλείδωμα (κουμπί 🔒)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ShiftSettingsSection() {
|
||
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 selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
|
||
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
|
||
return (
|
||
<div className="card divide-y divide-gray-100">
|
||
<div className="px-5 py-4">
|
||
<h2 className="font-semibold text-gray-700">Ρυθμίσεις Βάρδιας</h2>
|
||
<p className="text-xs text-gray-400 mt-0.5">Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους</p>
|
||
</div>
|
||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||
{!isLoading && (
|
||
<>
|
||
<div className="flex items-center justify-between px-5 py-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-800">Αυτόματη Έναρξη Βάρδιας</p>
|
||
<p className="text-xs text-gray-500 mt-0.5">Οι σερβιτόροι μπορούν να ξεκινούν μόνοι τους τη βάρδια τους</p>
|
||
</div>
|
||
<Toggle checked={selfStart === 'true'} onChange={() => toggle('shifts.waiter_self_start', selfStart)} disabled={updateMut.isPending} />
|
||
</div>
|
||
<div className="flex items-center justify-between px-5 py-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-800">Αυτόματο Κλείσιμο Βάρδιας</p>
|
||
<p className="text-xs text-gray-500 mt-0.5">Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους</p>
|
||
</div>
|
||
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Flag definitions ─────────────────────────────────────────────────────────
|
||
|
||
const FLAG_COLORS = [
|
||
'#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6',
|
||
'#8b5cf6', '#ec4899', '#06b6d4', '#6b7280', '#dc2626',
|
||
]
|
||
const RESTAURANT_EMOJIS = [
|
||
'🍽️', '🥂', '🍾', '🎂', '🎉', '🍰', '🥳', '👶', '🐶', '🐱',
|
||
'♿', '🌿', '🥗', '⭐', '💎', '🔥', '❄️', '⏳', '🧹', '⚠️',
|
||
]
|
||
|
||
function EmojiPicker({ value, onChange }) {
|
||
const [open, setOpen] = useState(false)
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<button type="button" onClick={() => setOpen(o => !o)} style={{
|
||
width: 60, height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||
background: 'white', fontSize: 20, textAlign: 'center', cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}>{value || '+'}</button>
|
||
{open && (
|
||
<div style={{
|
||
position: 'absolute', top: '110%', left: 0, zIndex: 200,
|
||
background: 'white', border: '1px solid #e2e8f0', borderRadius: 12,
|
||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)', padding: 8,
|
||
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 2, width: 180,
|
||
}}>
|
||
{RESTAURANT_EMOJIS.map(e => (
|
||
<button key={e} type="button" onClick={() => { onChange(e); setOpen(false) }} style={{
|
||
fontSize: 20, background: value === e ? '#eff3ff' : 'none',
|
||
border: 'none', borderRadius: 6, padding: '4px 0', cursor: 'pointer',
|
||
}}>{e}</button>
|
||
))}
|
||
<button type="button" onClick={() => { onChange(''); setOpen(false) }} style={{
|
||
fontSize: 11, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', borderRadius: 6,
|
||
}}>✕ clear</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FlagDisplayModeSection() {
|
||
const qc = useQueryClient()
|
||
const { data: settings } = 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('Σφάλμα αποθήκευσης'),
|
||
})
|
||
const current = settings?.['flags.display_mode']?.value ?? 'both'
|
||
const options = [
|
||
{ value: 'icon', label: '😀 Μόνο εικονίδιο' },
|
||
{ value: 'text', label: 'Aa Μόνο κείμενο' },
|
||
{ value: 'both', label: '😀 Aa Και τα δύο' },
|
||
]
|
||
return (
|
||
<div style={{ padding: '14px 20px', borderTop: '1px solid #f4f4f2' }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', marginBottom: 8, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||
Εμφάνιση σημαιών στις κάρτες τραπεζιών
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
{options.map(o => (
|
||
<button key={o.value} onClick={() => updateMut.mutate({ key: 'flags.display_mode', value: o.value })} style={{
|
||
height: 32, padding: '0 12px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||
border: `1.5px solid ${current === o.value ? '#3758c9' : '#dfe2e6'}`,
|
||
background: current === o.value ? '#eff3ff' : 'white',
|
||
color: current === o.value ? '#3758c9' : '#374151',
|
||
}}>{o.label}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FlagDefsSection() {
|
||
const qc = useQueryClient()
|
||
const [editingId, setEditingId] = useState(null)
|
||
const [editForm, setEditForm] = useState({})
|
||
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280', text_color: null })
|
||
const [showNew, setShowNew] = useState(false)
|
||
const { data: flags = [], isLoading } = useQuery({
|
||
queryKey: ['flag-defs'],
|
||
queryFn: () => client.get('/api/flags/defs?include_inactive=true').then(r => r.data),
|
||
staleTime: 30_000,
|
||
})
|
||
const createMut = useMutation({
|
||
mutationFn: (body) => client.post('/api/flags/defs', body),
|
||
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280', text_color: null }) },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
const updateMut = useMutation({
|
||
mutationFn: ({ id, ...body }) => client.put(`/api/flags/defs/${id}`, body),
|
||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setEditingId(null) },
|
||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||
})
|
||
const deleteMut = useMutation({
|
||
mutationFn: (id) => client.delete(`/api/flags/defs/${id}`),
|
||
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
function startEdit(flag) {
|
||
setEditingId(flag.id)
|
||
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', text_color: flag.text_color || null, sort_order: flag.sort_order })
|
||
}
|
||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
|
||
return (
|
||
<div className="card divide-y divide-gray-100">
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
|
||
<div>
|
||
<h2 className="font-semibold text-gray-700">Σημαίες Τραπεζιών</h2>
|
||
<p className="text-xs text-gray-400 mt-0.5">Χρησιμοποιούνται για να επισημαίνετε καταστάσεις στα τραπέζια</p>
|
||
</div>
|
||
<button onClick={() => setShowNew(v => !v)} style={{
|
||
height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||
}}>+ Νέα</button>
|
||
</div>
|
||
<FlagDisplayModeSection />
|
||
{showNew && (
|
||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
|
||
<EmojiPicker value={newForm.emoji} onChange={v => setNewForm(f => ({ ...f, emoji: v }))} />
|
||
<input placeholder="Όνομα σημαίας" value={newForm.name} onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))}
|
||
style={{ flex: 1, minWidth: 160, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
{FLAG_COLORS.map(c => (
|
||
<button key={c} onClick={() => setNewForm(f => ({ ...f, color: c }))}
|
||
style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Χρώμα γραφής:</span>
|
||
{[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => (
|
||
<button key={opt.label + opt.text} onClick={() => setNewForm(f => ({ ...f, text_color: opt.val }))}
|
||
style={{ width: 28, height: 28, borderRadius: 6, background: opt.bg, color: opt.text, fontSize: 14, fontWeight: 700, border: newForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>{opt.label}</button>
|
||
))}
|
||
</div>
|
||
<button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
|
||
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
|
||
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||
</div>
|
||
)}
|
||
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||
{!isLoading && flags.length === 0 && (
|
||
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Δεν υπάρχουν σημαίες ακόμα.</p>
|
||
)}
|
||
{flags.map(flag => (
|
||
<div key={flag.id} style={{ ...rowStyle, opacity: flag.is_active ? 1 : 0.45 }}>
|
||
{editingId === flag.id ? (
|
||
<div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||
<EmojiPicker value={editForm.emoji} onChange={v => setEditForm(f => ({ ...f, emoji: v }))} />
|
||
<input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))}
|
||
style={{ flex: 1, minWidth: 120, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
|
||
<div style={{ display: 'flex', gap: 3 }}>
|
||
{FLAG_COLORS.map(c => (
|
||
<button key={c} onClick={() => setEditForm(f => ({ ...f, color: c }))}
|
||
style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||
{[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => (
|
||
<button key={opt.text} onClick={() => setEditForm(f => ({ ...f, text_color: opt.val }))}
|
||
style={{ width: 24, height: 24, borderRadius: 6, background: editForm.color || '#6b7280', color: opt.text, fontSize: 13, fontWeight: 700, border: editForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>Α</button>
|
||
))}
|
||
</div>
|
||
<button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
|
||
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓</button>
|
||
<button onClick={() => setEditingId(null)}
|
||
style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}>✕</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: flag.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0 }}>
|
||
{flag.emoji || '🏷️'}
|
||
</div>
|
||
<span style={{ flex: 1, fontSize: 14, fontWeight: 500, color: '#111315' }}>{flag.name}</span>
|
||
{!flag.is_active && <span style={{ fontSize: 11, color: '#9ca3af', fontStyle: 'italic' }}>Ανενεργή</span>}
|
||
<button onClick={() => startEdit(flag)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία</button>
|
||
{flag.is_active && (
|
||
<button onClick={() => deleteMut.mutate(flag.id)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Quick message templates ──────────────────────────────────────────────────
|
||
|
||
function QuickTemplatesSection() {
|
||
const qc = useQueryClient()
|
||
const [editingId, setEditingId] = useState(null)
|
||
const [editBody, setEditBody] = useState('')
|
||
const [newBody, setNewBody] = useState('')
|
||
const [showNew, setShowNew] = useState(false)
|
||
const { data: templates = [], isLoading } = useQuery({
|
||
queryKey: ['quick-templates'],
|
||
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
|
||
staleTime: 30_000,
|
||
})
|
||
const createMut = useMutation({
|
||
mutationFn: (body) => client.post('/api/messages/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 }),
|
||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setEditingId(null) },
|
||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||
})
|
||
const deleteMut = useMutation({
|
||
mutationFn: (id) => client.delete(`/api/messages/templates/${id}`),
|
||
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }) },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
return (
|
||
<div className="card divide-y divide-gray-100">
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
|
||
<div>
|
||
<h2 className="font-semibold text-gray-700">Γρήγορα Μηνύματα</h2>
|
||
<p className="text-xs text-gray-400 mt-0.5">Πρότυπα μηνυμάτων για γρήγορη αποστολή στο προσωπικό</p>
|
||
</div>
|
||
<button onClick={() => setShowNew(v => !v)} style={{
|
||
height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||
}}>+ Νέο</button>
|
||
</div>
|
||
{showNew && (
|
||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||
<input placeholder="Κείμενο μηνύματος…" value={newBody} onChange={e => setNewBody(e.target.value)}
|
||
style={{ flex: 1, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
|
||
<button onClick={() => createMut.mutate({ body: newBody, sort_order: templates.length + 1 })}
|
||
disabled={!newBody.trim() || createMut.isPending}
|
||
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
|
||
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||
</div>
|
||
)}
|
||
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||
{!isLoading && templates.length === 0 && (
|
||
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Δεν υπάρχουν πρότυπα ακόμα.</p>
|
||
)}
|
||
{templates.map((t, idx) => (
|
||
<div key={t.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }}>
|
||
<span style={{ width: 22, fontSize: 12, color: '#9ca3af', fontWeight: 600, flexShrink: 0 }}>{idx + 1}.</span>
|
||
{editingId === t.id ? (
|
||
<>
|
||
<input value={editBody} onChange={e => setEditBody(e.target.value)}
|
||
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
|
||
<button onClick={() => updateMut.mutate({ id: t.id, body: editBody })} disabled={updateMut.isPending}
|
||
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓</button>
|
||
<button onClick={() => setEditingId(null)}
|
||
style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}>✕</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span style={{ flex: 1, fontSize: 14, color: '#111315' }}>{t.body}</span>
|
||
<button onClick={() => { setEditingId(t.id); setEditBody(t.body) }}
|
||
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία</button>
|
||
<button onClick={() => deleteMut.mutate(t.id)}
|
||
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function formatUptime(seconds) {
|
||
const h = Math.floor(seconds / 3600)
|
||
const m = Math.floor((seconds % 3600) / 60)
|
||
const s = seconds % 60
|
||
return `${h}ω ${m}λ ${s}δ`
|
||
}
|
||
|
||
export default function AppInfoTab() {
|
||
const user = useAuthStore(s => s.user)
|
||
const qc = useQueryClient()
|
||
const { data: status, isLoading } = useQuery({
|
||
queryKey: ['system-status'],
|
||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||
refetchInterval: 30_000,
|
||
})
|
||
const testPrint = useMutation({
|
||
mutationFn: (id) => client.post(`/api/system/printers/test?printer_id=${id}`),
|
||
onSuccess: (res) => {
|
||
const d = res.data
|
||
d.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${d.error}`)
|
||
},
|
||
onError: () => toast.error('Σφάλμα επικοινωνίας'),
|
||
})
|
||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* System info */}
|
||
<div className="card p-5 space-y-3">
|
||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<div className="text-gray-500">Uptime</div>
|
||
<div className="font-medium text-gray-800">{formatUptime(status?.uptime_seconds ?? 0)}</div>
|
||
<div className="text-gray-500">Άδεια χρήσης</div>
|
||
<div className={`font-medium ${status?.licensed ? 'text-green-700' : 'text-red-600'}`}>
|
||
{status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
|
||
</div>
|
||
<div className="text-gray-500">Κατάσταση</div>
|
||
<div className={`font-medium ${status?.locked ? 'text-red-600' : 'text-green-700'}`}>
|
||
{status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
|
||
</div>
|
||
{status?.expires_at && (
|
||
<>
|
||
<div className="text-gray-500">Λήξη άδειας</div>
|
||
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Printers */}
|
||
<div className="card divide-y divide-gray-100">
|
||
<div className="px-5 py-4">
|
||
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
|
||
</div>
|
||
{(!status?.printers || status.printers.length === 0) && (
|
||
<p className="px-5 py-6 text-center text-gray-400 text-sm">Δεν βρέθηκαν εκτυπωτές.</p>
|
||
)}
|
||
{status?.printers?.map(p => (
|
||
<div key={p.id} className="flex items-center gap-4 px-5 py-3">
|
||
<div className="flex-1">
|
||
<p className="font-medium text-gray-800">{p.name}</p>
|
||
</div>
|
||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${p.reachable ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||
{p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||
</span>
|
||
<button onClick={() => testPrint.mutate(p.id)} disabled={testPrint.isPending} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">
|
||
Test Print
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<ShiftSettingsSection />
|
||
<AutoLockSection />
|
||
<TimezoneSection />
|
||
<FlagDefsSection />
|
||
<QuickTemplatesSection />
|
||
|
||
{user?.role === 'sysadmin' && (
|
||
<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>
|
||
<div className="flex gap-3">
|
||
<button onClick={() => client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||
className="btn btn-primary text-sm">Ξεκλείδωμα</button>
|
||
<button onClick={() => client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||
className="btn btn-danger text-sm">Κλείδωμα</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|