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:
2026-04-29 12:12:23 +03:00
parent defc49f84f
commit bb39088464
78 changed files with 24370 additions and 1358 deletions

View 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>
)
}

View File

@@ -0,0 +1,511 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { DEFAULT_COLOURS } from '../../../store/tableColourStore'
import client from '../../../api/client'
import toast from 'react-hot-toast'
// ─── Colour slot metadata ────────────────────────────────────────────────────
const SLOTS = [
{ key: 'cardBg', label: 'Primary Background', hint: 'Card background' },
{ key: 'badgeBg', label: 'Secondary Background', hint: 'Status badge container' },
{ key: 'nameText', label: 'Primary Text', hint: 'Table name' },
{ key: 'badgeText', label: 'Secondary Text', hint: 'Badge label' },
]
const STATUSES = [
{ key: 'free', label: 'Free Table' },
{ key: 'open', label: 'Open Table (not mine)' },
{ key: 'mine', label: 'Open Table (assigned to me)' },
{ key: 'partially_paid', label: 'Partially Paid Table' },
{ key: 'paid', label: 'Fully Paid Table' },
]
const STATUS_LABELS_MOCK = {
free: 'ΕΛΕΥΘΕΡΟ',
open: 'ΑΝΟΙΧΤΟ',
mine: 'ΔΙΚΟ ΜΟΥ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
paid: 'ΠΛΗΡΩΜΕΝΟ',
}
// 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'],
}
// ─── Color picker modal ──────────────────────────────────────────────────────
// Parse any css colour string into { hex, alpha }.
// Handles: #rrggbb, #rgb, rgba(r,g,b,a), rgb(r,g,b)
function parseColour(v) {
if (!v) return { hex: '#ffffff', alpha: 1 }
const s = v.trim()
// rgba / rgb
const rgbaMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/)
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0')
const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0')
const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0')
const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1
return { hex: `#${r}${g}${b}`, alpha: Math.min(1, Math.max(0, a)) }
}
// #rgb shorthand
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
const [, r, g, b] = s
return { hex: `#${r}${r}${g}${g}${b}${b}`, alpha: 1 }
}
// #rrggbb
if (/^#[0-9a-fA-F]{6}$/.test(s)) return { hex: s, alpha: 1 }
return { hex: '#ffffff', alpha: 1 }
}
function buildColour(hex, alpha) {
if (alpha >= 1) return hex
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha.toFixed(2)})`
}
function ColourPickerModal({ value, onClose, onChange, slot }) {
const parsed = parseColour(value)
const [hex, setHex] = useState(parsed.hex)
const [alpha, setAlpha] = useState(parsed.alpha)
// keep parent in sync whenever hex or alpha changes
useEffect(() => { onChange(buildColour(hex, alpha)) }, [hex, alpha])
function commitSwatch(v) {
const p = parseColour(v)
setHex(p.hex)
setAlpha(p.alpha)
}
const preview = buildColour(hex, alpha)
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
onClick={onClose}
>
<div
style={{
background: '#fff', borderRadius: 20, padding: 28, width: '100%', maxWidth: 400,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Pick a Colour</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
</div>
{/* Preview swatch — checkerboard behind so alpha is visible */}
<div style={{
width: '100%', height: 56, borderRadius: 12, marginBottom: 20,
border: '1px solid #e5e7eb', overflow: 'hidden', position: 'relative',
backgroundImage: '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%)',
backgroundSize: '12px 12px',
backgroundPosition: '0 0,0 6px,6px -6px,-6px 0',
}}>
<div style={{
position: 'absolute', inset: 0, background: preview,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontFamily: 'monospace', color: alpha > 0.5 ? '#fff' : '#374151',
textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none',
}}>
{preview}
</div>
</div>
{/* Colour picker + hex input */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Colour</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
type="color"
value={hex}
onChange={e => setHex(e.target.value)}
style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }}
/>
<input
type="text"
value={hex}
onChange={e => {
const v = e.target.value
setHex(v)
}}
spellCheck={false}
style={{
flex: 1, height: 40, borderRadius: 8, border: '1px solid #e5e7eb',
padding: '0 12px', fontSize: 13, fontFamily: 'monospace', color: '#111827',
}}
/>
</div>
</div>
{/* Opacity slider — always visible */}
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>Opacity</div>
<div style={{ fontSize: 12, fontFamily: 'monospace', color: '#6b7280' }}>{Math.round(alpha * 100)}%</div>
</div>
{/* Gradient track so you can see what you're dragging */}
<div style={{
position: 'relative', height: 28,
background: `linear-gradient(to right, transparent, ${hex})`,
borderRadius: 8, border: '1px solid #e5e7eb',
backgroundImage: `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%),linear-gradient(to right,transparent,${hex})`,
backgroundSize: '10px 10px,10px 10px,10px 10px,10px 10px,100% 100%',
backgroundPosition: '0 0,0 5px,5px -5px,-5px 0,0 0',
}}>
<input
type="range"
min={0} max={1} step={0.01}
value={alpha}
onChange={e => setAlpha(parseFloat(e.target.value))}
style={{
position: 'absolute', inset: 0, width: '100%', height: '100%',
opacity: 0, cursor: 'pointer', margin: 0,
}}
/>
{/* thumb indicator */}
<div style={{
position: 'absolute', top: '50%', transform: 'translate(-50%,-50%)',
left: `${alpha * 100}%`,
width: 20, height: 20, borderRadius: '50%',
background: preview, border: '2px solid #fff',
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}} />
</div>
</div>
{/* Quick swatches */}
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Quick select</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(QUICK_SWATCHES[slot] || []).map(c => {
const p = parseColour(c)
const built = buildColour(p.hex, p.alpha)
return (
<button
key={c}
title={c}
onClick={() => commitSwatch(c)}
style={{
width: 36, height: 36, borderRadius: 8,
backgroundImage: `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%)`,
backgroundSize: '8px 8px',
backgroundPosition: '0 0,0 4px,4px -4px,-4px 0',
position: 'relative', overflow: 'hidden',
border: built === preview ? '3px solid #3758c9' : '2px solid #e5e7eb',
cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
}}
>
<div style={{ position: 'absolute', inset: 0, background: c }} />
</button>
)
})}
</div>
</div>
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid #f3f4f6', display: 'flex', gap: 10 }}>
<button
onClick={onClose}
style={{
flex: 1, height: 40, borderRadius: 10, border: '1px solid #e5e7eb',
background: '#f9fafb', fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}
>Done</button>
</div>
</div>
</div>
)
}
// ─── Single colour slot row ──────────────────────────────────────────────────
function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
<button
onClick={() => onOpen(mode, status, slotKey, value)}
style={{
width: 44, height: 28, borderRadius: 8, background: value,
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'monospace', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</div>
</div>
</div>
)
}
// ─── Mini mock table card (for preview) ──────────────────────────────────────
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
return (
<div style={{
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
position: 'relative', flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
{/* 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,
lineHeight: 1, letterSpacing: -0.5,
}}>{mockName}</span>
<span style={{
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
color: cfg.nameText + '80',
textTransform: 'uppercase',
}}>{groupName}</span>
</div>
{/* Status badge — tight equal padding on all sides */}
<div style={{
position: 'absolute', bottom: 7, left: 7,
background: cfg.badgeBg,
borderRadius: 4, padding: '2px 5px',
lineHeight: 1,
}}>
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
{label}
</span>
</div>
</div>
)
}
// ─── Preview panel (6 mock cards per theme) ──────────────────────────────────
function PreviewPanel({ colours, mode }) {
const isDark = mode === 'dark'
const panelBg = isDark ? '#0d1520' : '#f1f5f9'
const panelLabel = isDark ? '🌙 Dark Mode Preview' : '☀️ Light Mode Preview'
const labelCol = isDark ? '#94a3b8' : '#64748b'
const mockCards = [
{ status: 'free', name: 'TABLE 1', group: 'ΜΕΣΑ' },
{ status: 'open', name: 'TABLE 2', group: 'ΜΕΣΑ' },
{ status: 'mine', name: 'TABLE 3', group: 'ΜΕΣΑ' },
{ status: 'partially_paid', name: 'TABLE 4', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
{ status: 'paid', name: 'TABLE 5', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
{ status: 'free', name: 'TABLE 6', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
]
return (
<div style={{
background: panelBg, borderRadius: 16, padding: 16,
border: '1px solid ' + (isDark ? '#253245' : '#cbd5e1'),
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: labelCol, marginBottom: 12, letterSpacing: 0.3 }}>
{panelLabel}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{mockCards.map((mc, i) => (
<MockCard
key={i}
cfg={colours[mode][mc.status]}
label={STATUS_LABELS_MOCK[mc.status]}
mockName={mc.name}
groupName={mc.group}
/>
))}
</div>
</div>
)
}
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
function StatusBlock({ mode, status, label, colours, onOpen }) {
const cfg = colours[mode][status]
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" />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Click a swatch to edit</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>
</div>
)
}
// ─── Mode section (light or dark) ────────────────────────────────────────────
function ModeSection({ mode, colours, onOpen }) {
const label = mode === 'light' ? '☀️ Light Mode' : '🌙 Dark Mode'
return (
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111827', marginBottom: 14 }}>{label}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{STATUSES.map(s => (
<StatusBlock
key={s.key}
mode={mode}
status={s.key}
label={s.label}
colours={colours}
onOpen={onOpen}
/>
))}
</div>
</div>
)
}
// ─── Main tab ────────────────────────────────────────────────────────────────
export default function ColoursTab() {
const [colours, setColours] = useState(DEFAULT_COLOURS)
const [modal, setModal] = useState(null) // { mode, status, slot, value }
const [saving, setSaving] = useState(false)
const saveTimer = useRef(null)
// Load from backend on mount
useEffect(() => {
client.get('/api/settings/').then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) {
try { setColours(JSON.parse(raw)) } catch {}
}
})
}, [])
// Debounced save to backend — 600 ms after last change
const saveToBackend = useCallback((next) => {
clearTimeout(saveTimer.current)
setSaving(true)
saveTimer.current = setTimeout(() => {
client.put('/api/settings/ui.table_colours', { value: JSON.stringify(next) })
.then(() => setSaving(false))
.catch(() => { toast.error('Failed to save colours'); setSaving(false) })
}, 600)
}, [])
function setColour(mode, status, slot, value) {
setColours(prev => {
const next = {
...prev,
[mode]: {
...prev[mode],
[status]: { ...prev[mode][status], [slot]: value },
},
}
saveToBackend(next)
return next
})
}
function openModal(mode, status, slot, value) {
setModal({ mode, status, slot, value })
}
function handleChange(value) {
setColour(modal.mode, modal.status, modal.slot, value)
setModal(m => ({ ...m, value }))
}
function handleReset() {
if (window.confirm('Reset ALL colours to defaults? This cannot be undone.')) {
setColours(DEFAULT_COLOURS)
saveToBackend(DEFAULT_COLOURS)
}
}
return (
<div>
{/* Section header */}
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#111827', marginBottom: 4 }}>UI Personalization</h2>
<p style={{ fontSize: 13, color: '#6b7280' }}>
Customise how the Waiter App looks. Changes are saved to the server and sync to all devices automatically.
{saving && <span style={{ marginLeft: 8, color: '#9ca3af' }}>Saving</span>}
</p>
</div>
{/* Section: Waiter App — Table Colour Schemes */}
<div className="card" style={{ padding: 24 }}>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827', marginBottom: 4 }}>
Waiter App Table Colour Schemes
</div>
<p style={{ fontSize: 12, color: '#6b7280' }}>
Each table card has four colour slots. Click any colour swatch below to open the colour picker.
</p>
</div>
{/* Live previews side by side */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 32 }}>
<PreviewPanel colours={colours} mode="light" />
<PreviewPanel colours={colours} mode="dark" />
</div>
{/* 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} />
</div>
</div>
{/* Reset all button at bottom */}
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
height: 40, padding: '0 20px', borderRadius: 10,
border: '1.5px solid #fca5a5', background: '#fff5f5',
color: '#dc2626', fontSize: 14, fontWeight: 600, cursor: 'pointer',
}}
>
Reset All to Defaults
</button>
</div>
{/* Colour picker modal */}
{modal && (
<ColourPickerModal
value={modal.value}
slot={modal.slot}
onClose={() => setModal(null)}
onChange={handleChange}
/>
)}
</div>
)
}