Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
541
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
541
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
@@ -0,0 +1,541 @@
|
||||
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' })
|
||||
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' }) },
|
||||
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', 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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user