feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
458
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
458
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import client from '../../../api/client'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function StatsSection() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['system-stats'],
|
||||
queryFn: () => client.get('/api/system/stats').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const rows = [
|
||||
{ label: 'Κατηγορίες', value: stats?.categories },
|
||||
{ label: 'Προϊόντα (ενεργά)', value: stats?.products },
|
||||
{ label: 'Τραπέζια (ενεργά)', value: stats?.tables },
|
||||
{ label: 'Ζώνες Τραπεζιών', value: stats?.table_groups },
|
||||
{ label: 'Managers', value: stats?.managers },
|
||||
{ label: 'Σερβιτόροι', value: stats?.waiters },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="font-semibold text-gray-700">Στατιστικά Συστήματος</h2>
|
||||
{isLoading && <p className="text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{rows.map(({ label, value }) => (
|
||||
<>
|
||||
<div key={label + '-label'} className="text-gray-500">{label}</div>
|
||||
<div key={label + '-value'} className="font-medium text-gray-800">{value ?? '—'}</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function DoubleConfirmImport({ isOpen, onClose, onConfirm, title, summary, isPending }) {
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
function handleClose() {
|
||||
setStep(1)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleFirst() {
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
async function handleFinal() {
|
||||
await onConfirm()
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
{step === 1 && (
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<h3 className="font-semibold text-gray-800 text-lg">Επιβεβαίωση Εισαγωγής</h3>
|
||||
<p className="text-sm text-gray-600">{summary}</p>
|
||||
<p className="text-xs text-gray-400">Τα υπάρχοντα δεδομένα θα συγχωνευτούν — δεν θα διαγραφεί τίποτα.</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={handleClose} className="btn">Άκυρο</button>
|
||||
<button onClick={handleFirst} className="btn btn-primary">Συνέχεια →</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4 border-2 border-red-400">
|
||||
<h3 className="font-bold text-red-700 text-lg">⚠️ ΤΕΛΕΥΤΑΙΑ ΠΡΟΕΙΔΟΠΟΙΗΣΗ</h3>
|
||||
<p className="text-sm text-gray-700 font-medium">{title}</p>
|
||||
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
|
||||
Αυτή η ενέργεια <strong>δεν αναιρείται</strong>. Η βάση δεδομένων θα τροποποιηθεί μόνιμα.
|
||||
Είστε απολύτως σίγουροι;
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={handleClose} className="btn">Άκυρο</button>
|
||||
<button
|
||||
onClick={handleFinal}
|
||||
disabled={isPending}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
{isPending ? 'Εισαγωγή…' : 'ΝΑΙ, ΕΙΣΑΓΩΓΗ'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function DataTransferSection() {
|
||||
const qc = useQueryClient()
|
||||
const [catalogModal, setCatalogModal] = useState(false)
|
||||
const [tablesModal, setTablesModal] = useState(false)
|
||||
const [catalogPayload, setCatalogPayload] = useState(null)
|
||||
const [tablesPayload, setTablesPayload] = useState(null)
|
||||
const [catalogSummary, setCatalogSummary] = useState('')
|
||||
const [tablesSummary, setTablesSummary] = useState('')
|
||||
|
||||
const catalogImportMut = useMutation({
|
||||
mutationFn: (payload) => client.post('/api/data-transfer/import/catalog', payload).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Κατάλογος εισήχθη επιτυχώς')
|
||||
setCatalogModal(false)
|
||||
setCatalogPayload(null)
|
||||
qc.invalidateQueries({ queryKey: ['system-stats'] })
|
||||
qc.invalidateQueries({ queryKey: ['products'] })
|
||||
qc.invalidateQueries({ queryKey: ['categories'] })
|
||||
},
|
||||
onError: (err) => toast.error(err?.response?.data?.detail ?? 'Σφάλμα εισαγωγής'),
|
||||
})
|
||||
|
||||
const tablesImportMut = useMutation({
|
||||
mutationFn: (payload) => client.post('/api/data-transfer/import/tables', payload).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Τραπέζια εισήχθησαν επιτυχώς')
|
||||
setTablesModal(false)
|
||||
setTablesPayload(null)
|
||||
qc.invalidateQueries({ queryKey: ['system-stats'] })
|
||||
qc.invalidateQueries({ queryKey: ['tables'] })
|
||||
},
|
||||
onError: (err) => toast.error(err?.response?.data?.detail ?? 'Σφάλμα εισαγωγής'),
|
||||
})
|
||||
|
||||
function handleExport(bundle) {
|
||||
client.get(`/api/data-transfer/export/${bundle}`, { responseType: 'blob' })
|
||||
.then(res => {
|
||||
const disposition = res.headers['content-disposition'] ?? ''
|
||||
const match = disposition.match(/filename="([^"]+)"/)
|
||||
const filename = match ? match[1] : `xenia-${bundle}.json`
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
.catch(() => toast.error('Σφάλμα εξαγωγής'))
|
||||
}
|
||||
|
||||
function handleFileSelect(bundle, e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.target.result)
|
||||
if (parsed.bundle !== bundle) {
|
||||
toast.error(`Λάθος αρχείο. Αναμένεται αρχείο τύπου '${bundle}'.`)
|
||||
return
|
||||
}
|
||||
if (bundle === 'catalog') {
|
||||
const cats = parsed.data?.categories ?? []
|
||||
const orphans = parsed.data?.uncategorized_products ?? []
|
||||
const prodCount = cats.reduce((acc, c) => acc + (c.products?.length ?? 0), 0) + orphans.length
|
||||
setCatalogSummary(`Πρόκειται να εισαγάγετε ${cats.length} κατηγορίες και ${prodCount} προϊόντα.`)
|
||||
setCatalogPayload(parsed)
|
||||
setCatalogModal(true)
|
||||
} else {
|
||||
const groups = parsed.data?.table_groups ?? []
|
||||
const ungrouped = parsed.data?.ungrouped_tables ?? []
|
||||
const tableCount = groups.reduce((acc, g) => acc + (g.tables?.length ?? 0), 0) + ungrouped.length
|
||||
setTablesSummary(`Πρόκειται να εισαγάγετε ${groups.length} ζώνες και ${tableCount} τραπέζια.`)
|
||||
setTablesPayload(parsed)
|
||||
setTablesModal(true)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Μη έγκυρο αρχείο JSON')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Κατάλογος Προϊόντων</p>
|
||||
<p className="text-xs text-gray-500">Κατηγορίες + Προϊόντα (χωρίς εκτυπωτές)</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleExport('catalog')} className="btn text-sm">
|
||||
Εξαγωγή
|
||||
</button>
|
||||
<label className="btn text-sm cursor-pointer">
|
||||
Εισαγωγή
|
||||
<input type="file" accept=".json" className="hidden" onChange={(e) => handleFileSelect('catalog', e)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Τραπέζια & Ζώνες</p>
|
||||
<p className="text-xs text-gray-500">Ζώνες τραπεζιών + Τραπέζια</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleExport('tables')} className="btn text-sm">
|
||||
Εξαγωγή
|
||||
</button>
|
||||
<label className="btn text-sm cursor-pointer">
|
||||
Εισαγωγή
|
||||
<input type="file" accept=".json" className="hidden" onChange={(e) => handleFileSelect('tables', e)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DoubleConfirmImport
|
||||
isOpen={catalogModal}
|
||||
onClose={() => { setCatalogModal(false); setCatalogPayload(null) }}
|
||||
onConfirm={() => catalogImportMut.mutateAsync(catalogPayload)}
|
||||
title="Εισαγωγή Καταλόγου Προϊόντων"
|
||||
summary={catalogSummary}
|
||||
isPending={catalogImportMut.isPending}
|
||||
/>
|
||||
<DoubleConfirmImport
|
||||
isOpen={tablesModal}
|
||||
onClose={() => { setTablesModal(false); setTablesPayload(null) }}
|
||||
onConfirm={() => tablesImportMut.mutateAsync(tablesPayload)}
|
||||
title="Εισαγωγή Τραπεζιών & Ζωνών"
|
||||
summary={tablesSummary}
|
||||
isPending={tablesImportMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function QRModal({ url, onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 flex flex-col items-center gap-5 max-w-sm w-full mx-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-bold text-gray-800 text-lg">QR Σύνδεσης</h3>
|
||||
<p className="text-xs text-gray-500 text-center break-all">{url}</p>
|
||||
<div className="p-3 bg-white border border-gray-200 rounded-xl">
|
||||
<QRCodeSVG value={url} size={220} includeMargin={false} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
|
||||
</p>
|
||||
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</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 [refreshing, setRefreshing] = useState(false)
|
||||
const [qrOpen, setQrOpen] = useState(false)
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
async function handleRefresh() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await client.post('/api/system/sync-license')
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['system-status'] }),
|
||||
qc.invalidateQueries({ queryKey: ['license-status'] }),
|
||||
])
|
||||
} catch {
|
||||
// sync-license failure just means cloud was unreachable — still refresh local caches
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['system-status'] }),
|
||||
qc.invalidateQueries({ queryKey: ['license-status'] }),
|
||||
])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Ανανέωση κατάστασης"
|
||||
className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-gray-200 bg-white text-gray-500 text-xs font-medium hover:bg-gray-50 hover:text-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className={refreshing ? 'animate-spin inline-block' : 'inline-block'}>⭮</span>
|
||||
{refreshing ? 'Ανανέωση…' : 'Ανανέωση'}
|
||||
</button>
|
||||
</div>
|
||||
<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 text-gray-800 flex items-center gap-2">
|
||||
{status?.version ?? '—'}
|
||||
{status?.latest_version && status.latest_version !== status.version && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
|
||||
Διαθέσιμη {status.latest_version}
|
||||
</span>
|
||||
)}
|
||||
{status?.latest_version && status.latest_version === status.version && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-700">
|
||||
Ενημερωμένο
|
||||
</span>
|
||||
)}
|
||||
</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'
|
||||
: status?.lock_pending ? 'text-amber-600'
|
||||
: 'text-green-700'
|
||||
}`}>
|
||||
{status?.locked ? 'Κλειδωμένο'
|
||||
: status?.lock_pending ? 'Εκκρεμεί Κλείδωμα'
|
||||
: 'Λειτουργικό'}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
{status?.waiter_domain && (
|
||||
<>
|
||||
<div className="text-gray-500">Waiter Domain</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
|
||||
<button
|
||||
onClick={() => setQrOpen(true)}
|
||||
className="flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
|
||||
>
|
||||
QR Code
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimezoneSection />
|
||||
|
||||
<StatsSection />
|
||||
|
||||
<DataTransferSection />
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{qrOpen && status?.waiter_domain && (
|
||||
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
494
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
494
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
@@ -0,0 +1,494 @@
|
||||
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: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
|
||||
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
|
||||
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
|
||||
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
|
||||
]
|
||||
|
||||
const STATUSES = [
|
||||
{ key: 'free', label: 'Ελεύθερο' },
|
||||
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
|
||||
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
|
||||
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
|
||||
{ key: 'paid', label: 'Πληρωμένο' },
|
||||
]
|
||||
|
||||
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' }}>Επιλογή Χρώματος</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 }}>Χρώμα</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' }}>Διαφάνεια</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 }}>Γρήγορη επιλογή</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',
|
||||
}}
|
||||
>Κλείσιμο</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 ? '🌙 Προεπισκόπηση σκοτεινού θέματος' : '☀️ Προεπισκόπηση φωτεινού θέματος'
|
||||
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 }}>Πατήστε ένα χρώμα για επεξεργασία</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' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
|
||||
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('Σφάλμα αποθήκευσης χρωμάτων'); 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('Επαναφορά όλων των χρωμάτων στις προεπιλογές; Δεν μπορεί να αναιρεθεί.')) {
|
||||
setColours(DEFAULT_COLOURS)
|
||||
saveToBackend(DEFAULT_COLOURS)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
{saving && <p style={{ fontSize: 12, color: '#9ca3af', marginBottom: 16 }}>Αποθήκευση…</p>}
|
||||
|
||||
{/* 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',
|
||||
}}
|
||||
>
|
||||
Επαναφορά προεπιλογών
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Colour picker modal */}
|
||||
{modal && (
|
||||
<ColourPickerModal
|
||||
value={modal.value}
|
||||
slot={modal.slot}
|
||||
onClose={() => setModal(null)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal file
74
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
|
||||
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 ? '#dc2626' : '#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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DevelopmentTab() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }) },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
|
||||
const spoofOn = settings?.['dev.spoof_printing']?.value === 'true'
|
||||
|
||||
function toggleSpoof(val) {
|
||||
mutation.mutate({ key: 'dev.spoof_printing', value: val ? 'true' : 'false' })
|
||||
toast.success(val ? 'Spoof mode ενεργό — εκτυπωτές σιωπηλοί' : 'Spoof mode ανενεργό — εκτυπωτές ενεργοί')
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-48 text-gray-400 text-sm">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-800">
|
||||
Αυτές οι ρυθμίσεις προορίζονται μόνο για δοκιμές. Μην τις αφήνετε ενεργές σε παραγωγικό περιβάλλον.
|
||||
</div>
|
||||
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-700">Spoof Printer Mode</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Όλες οι εκτυπώσεις απορρίπτονται αθόρυβα. Οι συσκευές συμπεριφέρονται σαν η εκτύπωση να πέτυχε.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={spoofOn} onChange={toggleSpoof} disabled={mutation.isPending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spoofOn && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 font-medium">
|
||||
Spoof mode ενεργό — οι εκτυπωτές είναι σιωπηλοί.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
338
manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
Normal file
338
manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
|
||||
function Toggle({ checked, onChange, disabled }) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors duration-200 flex-shrink-0 disabled:opacity-50 ${
|
||||
checked ? 'bg-sky-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-all duration-200 ${
|
||||
checked ? 'left-6' : 'left-1'
|
||||
}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children, action }) {
|
||||
return (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">{title}</h2>
|
||||
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionRow({ label, description, children }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800">{label}</p>
|
||||
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</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 (
|
||||
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<OptionRow label="Αυτόματη Έναρξη Βάρδιας" description="Οι σερβιτόροι μπορούν να ξεκινούν μόνοι τους τη βάρδια τους">
|
||||
<Toggle checked={selfStart === 'true'} onChange={() => toggle('shifts.waiter_self_start', selfStart)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους">
|
||||
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 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 toggleMut = useMutation({
|
||||
mutationFn: (id) => client.patch(`/api/flags/defs/${id}/toggle-active`),
|
||||
onSuccess: (res) => { toast.success(res.data.is_active ? 'Ενεργοποιήθηκε' : 'Απενεργοποιήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/flags/defs/${id}`),
|
||||
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
|
||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
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' }
|
||||
const newRowButton = (
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<SectionCard title="Σημάνσεις Τραπεζιών" description="Χρησιμοποιούνται για να επισημαίνετε καταστάσεις στα τραπέζια" action={newRowButton}>
|
||||
{showNew && (
|
||||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', borderTop: '1px solid #f4f4f2' }}>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => toggleMut.mutate(flag.id)}
|
||||
disabled={toggleMut.isPending}
|
||||
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fed7aa', background: '#fff7ed', fontSize: 12, cursor: 'pointer', color: '#c2410c' }}
|
||||
>{flag.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Να διαγραφεί οριστικά η σήμανση "${flag.name}";`)) deleteMut.mutate(flag.id)
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}
|
||||
>Διαγραφή</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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('Σφάλμα'),
|
||||
})
|
||||
const newButton = (
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<SectionCard title="Γρήγορα Μηνύματα" description="Πρότυπα μηνυμάτων για γρήγορη αποστολή στο προσωπικό" action={newButton}>
|
||||
{showNew && (
|
||||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', gap: 10, alignItems: 'center', borderTop: '1px solid #f4f4f2' }}>
|
||||
<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>
|
||||
))}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OperationTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ShiftSettingsSection />
|
||||
<FlagDefsSection />
|
||||
<QuickTemplatesSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal file
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
|
||||
// ── Font option definitions ────────────────────────────────────────────────
|
||||
// Value encodes: "SIZE:BOLD:CAPS"
|
||||
// SIZE: ESC ! base byte — 0=normal, 16=tall, 32=wide, 48=tall+wide
|
||||
// BOLD: 0|1 CAPS: 0|1
|
||||
const FONT_SIZE_OPTIONS = [
|
||||
{ size: '0', label: 'Μικρά' },
|
||||
{ size: '16', label: 'Ψηλά' },
|
||||
{ size: '32', label: 'Πλατιά' },
|
||||
{ size: '48', label: 'Ψηλά και Πλατιά' },
|
||||
]
|
||||
|
||||
function encodeFont(size, bold, caps) {
|
||||
return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
|
||||
}
|
||||
function decodeFont(val) {
|
||||
if (!val) return { size: '0', bold: false, caps: false }
|
||||
const [size, bold, caps] = val.split(':')
|
||||
return { size: size ?? '0', bold: bold === '1', caps: caps === '1' }
|
||||
}
|
||||
|
||||
const DIVIDER_OPTIONS = [
|
||||
{ value: 'dash', label: 'Παύλες ( - )', chars: '-------------------' },
|
||||
{ value: 'equals', label: 'Ίσον ( = )', chars: '===================' },
|
||||
{ value: 'star', label: 'Αστερίσκοι ( * )', chars: '*******************' },
|
||||
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
||||
]
|
||||
|
||||
const FONT_DEFAULTS = {
|
||||
'print.font_order_number': '48:1:0',
|
||||
'print.font_meta': '0:0:0',
|
||||
'print.font_item_name': '16:1:0',
|
||||
'print.font_quick': '0:0:0',
|
||||
'print.font_pref': '0:0:0',
|
||||
'print.font_extra': '0:0:0',
|
||||
'print.font_ingredient': '0:0:0',
|
||||
'print.font_item_note': '0:0:0',
|
||||
'print.font_order_note': '0:1:0',
|
||||
'print.divider_style': 'dash',
|
||||
'print.ticket_mode': 'detailed',
|
||||
}
|
||||
|
||||
// ── Preview ────────────────────────────────────────────────────────────────
|
||||
const PREVIEW_W = 200
|
||||
const PREVIEW_H = 50
|
||||
|
||||
const sizeStyle = {
|
||||
'0': { fontSize: 13, scaleY: 1, scaleX: 1 },
|
||||
'16': { fontSize: 13, scaleY: 1.9, scaleX: 1 },
|
||||
'32': { fontSize: 13, scaleY: 1, scaleX: 1.9 },
|
||||
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
|
||||
}
|
||||
|
||||
function FontPreview({ size, bold, caps }) {
|
||||
const s = sizeStyle[size] ?? sizeStyle['0']
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1a1a', borderRadius: 8,
|
||||
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#f5f5f5',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
fontSize: s.fontSize,
|
||||
fontWeight: bold ? 800 : 400,
|
||||
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
|
||||
transformOrigin: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}>
|
||||
{caps ? 'SAMPLE' : 'Sample'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toggle button (shared) ─────────────────────────────────────────────────
|
||||
function ToggleBtn({ active, onClick, disabled, label }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
|
||||
border: `1.5px solid ${active ? '#3758c9' : '#dfe2e6'}`,
|
||||
background: active ? '#eff3ff' : 'white',
|
||||
color: active ? '#3758c9' : '#6b7280',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: `2px solid ${active ? '#3758c9' : '#9ca3af'}`,
|
||||
background: active ? '#3758c9' : 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{active && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}>✓</span>}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single font row ────────────────────────────────────────────────────────
|
||||
function FontRow({ field, value, onChange, isPending, nested = false }) {
|
||||
const { size, bold, caps } = decodeFont(value)
|
||||
|
||||
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold, caps)) }
|
||||
function handleBold() { onChange(field.key, encodeFont(size, !bold, caps)) }
|
||||
function handleCaps() { onChange(field.key, encodeFont(size, bold, !caps)) }
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: nested ? '10px 20px 10px 36px' : '14px 20px',
|
||||
borderBottom: '1px solid #f4f4f2',
|
||||
background: nested ? '#fafafa' : 'white',
|
||||
}}>
|
||||
{nested && (
|
||||
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}>└</span>
|
||||
)}
|
||||
{/* Label */}
|
||||
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||
<span style={{ fontSize: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||
{field.label}
|
||||
</span>
|
||||
{field.sub && (
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size dropdown */}
|
||||
<select
|
||||
value={size}
|
||||
onChange={handleSize}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||||
background: 'white', padding: '0 10px', fontSize: 13,
|
||||
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{FONT_SIZE_OPTIONS.map(o => (
|
||||
<option key={o.size} value={o.size}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Bold toggle */}
|
||||
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
|
||||
|
||||
{/* Caps toggle */}
|
||||
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
|
||||
|
||||
{/* Preview */}
|
||||
<FontPreview size={size} bold={bold} caps={caps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Subgroup header row ────────────────────────────────────────────────────
|
||||
function SubgroupHeader({ label }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 20px 6px',
|
||||
borderBottom: '1px solid #f4f4f2',
|
||||
background: '#f9fafb',
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Divider row ────────────────────────────────────────────────────────────
|
||||
function DividerRow({ value, onChange, isPending }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 20px',
|
||||
}}>
|
||||
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||
Στυλ Διαχωριστικού
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>Ανάμεσα στις ενότητες κάθε ticket</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange('print.divider_style', e.target.value)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||||
background: 'white', padding: '0 10px', fontSize: 13,
|
||||
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{DIVIDER_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* spacer to align with bold+caps column */}
|
||||
<div style={{ width: 194, flexShrink: 0 }} />
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
background: '#1a1a1a', borderRadius: 8,
|
||||
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{value === 'empty'
|
||||
? <span style={{ color: '#6b7280', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif' }}>(κενή γραμμή)</span>
|
||||
: <span style={{ color: '#f5f5f5', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif', letterSpacing: 2 }}>
|
||||
{DIVIDER_OPTIONS.find(o => o.value === value)?.chars}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Ticket mode section ────────────────────────────────────────────────────
|
||||
function TicketModeSection({ value, onChange, isPending, printers }) {
|
||||
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
||||
const [printing, setPrinting] = useState(false)
|
||||
|
||||
// Auto-select first active printer
|
||||
useEffect(() => {
|
||||
if (printers.length > 0 && !selectedPrinter) {
|
||||
const first = printers.find(p => p.is_active) ?? printers[0]
|
||||
setSelectedPrinter(first.id)
|
||||
}
|
||||
}, [printers])
|
||||
|
||||
async function handleTestOrder() {
|
||||
if (!selectedPrinter) return
|
||||
setPrinting(true)
|
||||
try {
|
||||
const res = await client.post(`/api/system/printers/test-order?printer_id=${selectedPrinter}`)
|
||||
if (res.data.success) toast.success('Test order στάλθηκε!')
|
||||
else toast.error(`Σφάλμα: ${res.data.error}`)
|
||||
} catch {
|
||||
toast.error('Σφάλμα επικοινωνίας')
|
||||
} finally {
|
||||
setPrinting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Τύπος Εκτύπωσης</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, padding: '16px 20px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{
|
||||
key: 'detailed',
|
||||
title: 'Αναλυτικό',
|
||||
desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.',
|
||||
},
|
||||
{
|
||||
key: 'compact',
|
||||
title: 'Συμπαγές',
|
||||
desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.',
|
||||
},
|
||||
].map(opt => {
|
||||
const active = value === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => onChange('print.ticket_mode', opt.key)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
|
||||
borderRadius: 10, cursor: 'pointer',
|
||||
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
|
||||
background: active ? '#eff3ff' : 'white',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 4 }}>
|
||||
{opt.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>{opt.desc}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Test order button */}
|
||||
<button
|
||||
onClick={handleTestOrder}
|
||||
disabled={printing || !selectedPrinter}
|
||||
style={{
|
||||
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
|
||||
borderRadius: 10, cursor: printing || !selectedPrinter ? 'default' : 'pointer',
|
||||
border: '2px solid #e5e7eb',
|
||||
background: printing ? '#f9fafb' : 'white',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: printing ? '#9ca3af' : '#111315', marginBottom: 4 }}>
|
||||
{printing ? 'Εκτύπωση…' : 'Δοκιμαστική Εκτύπωση'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||
Εκτυπώνει fake παραγγελία με όλους τους τύπους επιλογών για προεπισκόπηση ρυθμίσεων.
|
||||
</div>
|
||||
</div>
|
||||
{printers.length > 0 && (
|
||||
<div style={{ marginTop: 10 }} onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
value={selectedPrinter ?? ''}
|
||||
onChange={e => setSelectedPrinter(Number(e.target.value))}
|
||||
disabled={printing}
|
||||
style={{
|
||||
width: '100%', height: 32, borderRadius: 6,
|
||||
border: '1px solid #dfe2e6', background: 'white',
|
||||
padding: '0 8px', fontSize: 12, color: '#374151', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{printers.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{printers.length === 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444' }}>
|
||||
Δεν υπάρχουν εκτυπωτές
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Printers section ───────────────────────────────────────────────────────
|
||||
|
||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
|
||||
|
||||
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||
function set(k, v) { setForm(f => ({ ...f, [k]: v })) }
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 10,
|
||||
padding: '16px 20px', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 160px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΟΝΟΜΑ</label>
|
||||
<input value={form.name} onChange={e => set('name', e.target.value)}
|
||||
placeholder="π.χ. Κουζίνα" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 130px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>IP ADDRESS</label>
|
||||
<input value={form.ip_address} onChange={e => set('ip_address', e.target.value)}
|
||||
placeholder="10.98.20.25" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 80px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
|
||||
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
|
||||
type="number" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
|
||||
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
|
||||
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
||||
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
||||
style={btnPrimary}>Αποθήκευση</button>
|
||||
<button onClick={onCancel} style={btnSecondary}>Άκυρο</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white',
|
||||
padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%',
|
||||
}
|
||||
const btnPrimary = {
|
||||
height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white',
|
||||
border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}
|
||||
const btnSecondary = {
|
||||
height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6',
|
||||
background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151',
|
||||
}
|
||||
const btnDanger = {
|
||||
height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2',
|
||||
background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626',
|
||||
}
|
||||
|
||||
function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) {
|
||||
const [reachable, setReachable] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
client.get('/api/system/status').then(r => {
|
||||
if (cancelled) return
|
||||
const match = r.data.printers?.find(p => p.id === printer.id)
|
||||
if (match) setReachable(match.reachable)
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [printer.id])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 20px', borderBottom: '1px solid #f4f4f2',
|
||||
opacity: printer.is_active ? 1 : 0.5,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
|
||||
style={{
|
||||
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
|
||||
background: printer.is_active ? '#16a34a' : '#d1d5db', position: 'relative', transition: 'background 150ms',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3, left: printer.is_active ? 21 : 3,
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 120 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
|
||||
{printer.ip_address}:{printer.port}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
|
||||
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
|
||||
color: reachable === null ? '#9ca3af' : reachable ? '#16a34a' : '#dc2626',
|
||||
}}>
|
||||
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
|
||||
<button onClick={() => onTest(printer.id)} disabled={testPending}
|
||||
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||
Test Print
|
||||
</button>
|
||||
<button onClick={() => onEdit(printer)}
|
||||
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||
Επεξεργασία
|
||||
</button>
|
||||
<button onClick={() => onDelete(printer.id)} style={{ ...btnDanger, flexShrink: 0 }}>
|
||||
Διαγραφή
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrintersSection() {
|
||||
const qc = useQueryClient()
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
|
||||
const { data: printers = [], isLoading } = useQuery({
|
||||
queryKey: ['printers-all'],
|
||||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: body => client.post('/api/system/printers', body),
|
||||
onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) },
|
||||
onError: () => toast.error('Σφάλμα δημιουργίας'),
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: id => client.delete(`/api/system/printers/${id}`),
|
||||
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) },
|
||||
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||
})
|
||||
const testMut = useMutation({
|
||||
mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`),
|
||||
onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`),
|
||||
onError: () => toast.error('Σφάλμα επικοινωνίας'),
|
||||
})
|
||||
|
||||
function handleToggle(printer) {
|
||||
updateMut.mutate({ id: printer.id, is_active: !printer.is_active })
|
||||
}
|
||||
|
||||
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); setEditingId(null) }} style={btnSecondary}>
|
||||
+ Νέος εκτυπωτής
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNew && (
|
||||
<div style={{ padding: '12px 20px' }}>
|
||||
<PrinterForm
|
||||
onSave={form => createMut.mutate(form)}
|
||||
onCancel={() => setShowNew(false)}
|
||||
isPending={createMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||||
{!isLoading && printers.length === 0 && !showNew && (
|
||||
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
|
||||
Δεν υπάρχουν εκτυπωτές ακόμα.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{printers.map(printer => (
|
||||
editingId === printer.id ? (
|
||||
<div key={printer.id} style={{ padding: '12px 20px', borderBottom: '1px solid #f4f4f2' }}>
|
||||
<PrinterForm
|
||||
initial={printer}
|
||||
onSave={form => updateMut.mutate({ id: printer.id, ...form })}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isPending={updateMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PrinterRow
|
||||
key={printer.id}
|
||||
printer={printer}
|
||||
onEdit={p => { setEditingId(p.id); setShowNew(false) }}
|
||||
onDelete={id => deleteMut.mutate(id)}
|
||||
onTest={id => testMut.mutate(id)}
|
||||
onToggle={handleToggle}
|
||||
testPending={testMut.isPending}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Font groups definition ─────────────────────────────────────────────────
|
||||
const FONT_GROUPS = [
|
||||
{
|
||||
group: 'Αριθμός Παραγγελίας',
|
||||
fields: [
|
||||
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: '"Παραγγελια #42" — η επικεφαλίδα του ticket' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Επικεφαλίδα Ticket',
|
||||
fields: [
|
||||
{ key: 'print.font_meta', label: 'Τραπέζι · Σερβιτόρος · Ώρα', sub: 'Γραμμές ταυτότητας κάτω από τον αριθμό' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Αντικείμενα',
|
||||
fields: [
|
||||
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Το κυρίως πιάτο/ποτό — γραμμή dot-leader' },
|
||||
{ key: 'print.font_quick', label: '* Quick Options', sub: 'Γρήγορες επιλογές ( * )' },
|
||||
{ key: 'print.font_pref', label: '> Προτιμήσεις', sub: 'Επιλογές preference sets ( > )' },
|
||||
{ key: 'print.font_extra', label: '+ Extras', sub: 'Πρόσθετα / τροποποιητές ( + )' },
|
||||
{ key: 'print.font_ingredient', label: '- Αφαιρέσεις', sub: 'ΧΩΡΙΣ: συστατικά ( - )' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Σημειώσεις',
|
||||
fields: [
|
||||
{ key: 'print.font_item_note', label: '(!) Σημείωση Αντικειμένου', sub: 'Free-text σημείωση ανά πιάτο' },
|
||||
{ key: 'print.font_order_note', label: 'Σημειώσεις Παραγγελίας', sub: 'Η γενική σημείωση της παραγγελίας' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Main tab ───────────────────────────────────────────────────────────────
|
||||
export default function PrintFontsTab() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers-all'],
|
||||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||
staleTime: 15_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 val(key) { return settings?.[key]?.value ?? FONT_DEFAULTS[key] }
|
||||
function handleChange(key, value) { updateMut.mutate({ key, value }) }
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={{ padding: 40, textAlign: 'center', color: '#9ca3af', fontSize: 14 }}>Φόρτωση…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* 1. Printers */}
|
||||
<PrintersSection />
|
||||
|
||||
{/* 2. Ticket mode */}
|
||||
<TicketModeSection
|
||||
value={val('print.ticket_mode')}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
printers={printers}
|
||||
/>
|
||||
|
||||
{/* 3. Font sizes — grouped */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{FONT_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<SubgroupHeader label={group.group} />
|
||||
{group.fields.map((field, idx) => (
|
||||
<FontRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={val(field.key)}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
nested={group.fields.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 4. Divider style */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||
</div>
|
||||
<DividerRow
|
||||
value={val('print.divider_style')}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10,
|
||||
padding: '12px 16px', fontSize: 12, color: '#92400e', lineHeight: 1.6,
|
||||
}}>
|
||||
<strong>Σημείωση:</strong> Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48.
|
||||
Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
270
manager_dashboard/src/pages/Settings/tabs/SecurityTab.jsx
Normal file
270
manager_dashboard/src/pages/Settings/tabs/SecurityTab.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Shield, Lock, LogOut, User, KeyRound } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { PANEL_CLASS } from '../../../ui/tokens'
|
||||
import Button from '../../../ui/Button'
|
||||
|
||||
const INACTIVITY_PRESETS = [
|
||||
{ label: '5 δευτερόλεπτα', value: 5 },
|
||||
{ label: '10 δευτερόλεπτα', value: 10 },
|
||||
{ label: '30 δευτερόλεπτα', value: 30 },
|
||||
{ label: '1 λεπτό', value: 60 },
|
||||
{ label: '2 λεπτά', value: 120 },
|
||||
{ label: '5 λεπτά', value: 300 },
|
||||
{ label: '10 λεπτά', value: 600 },
|
||||
{ label: '15 λεπτά', value: 900 },
|
||||
{ label: '30 λεπτά', value: 1800 },
|
||||
{ label: '1 ώρα', value: 3600 },
|
||||
]
|
||||
|
||||
function fmtSeconds(s) {
|
||||
const n = parseInt(s, 10)
|
||||
if (isNaN(n) || n <= 0) return ''
|
||||
if (n < 60) return `${n}s`
|
||||
if (n < 3600) return `${Math.round(n / 60)}m`
|
||||
return `${Math.round(n / 3600)}h`
|
||||
}
|
||||
|
||||
// ─── Shared primitives ────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div className={`${PANEL_CLASS} divide-y divide-gray-100`}>
|
||||
<div className="flex items-start gap-3 px-5 py-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-50 flex-shrink-0 mt-0.5">
|
||||
<Icon className="h-4 w-4 text-sky-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-700">{title}</p>
|
||||
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionRow({ label, description, children }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800">{label}</p>
|
||||
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors duration-200 flex-shrink-0 disabled:opacity-50 ${
|
||||
checked ? 'bg-sky-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-all duration-200 ${
|
||||
checked ? 'left-6' : 'left-1'
|
||||
}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl({ options, value, onChange, disabled }) {
|
||||
return (
|
||||
<div className="flex rounded-lg border border-gray-200 overflow-hidden bg-gray-50 p-0.5 gap-0.5">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(opt.value)}
|
||||
disabled={disabled}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
|
||||
value === opt.value
|
||||
? 'bg-white text-gray-800 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSelect({ value, onChange, disabled }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-8 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 focus:outline-none focus:border-sky-400 disabled:opacity-50"
|
||||
>
|
||||
{INACTIVITY_PRESETS.map(p => (
|
||||
<option key={p.value} value={String(p.value)}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SecurityTab() {
|
||||
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 get(key, fallback) {
|
||||
return settings?.[key]?.value ?? fallback
|
||||
}
|
||||
|
||||
function save(key, value) {
|
||||
updateMut.mutate({ key, value })
|
||||
}
|
||||
|
||||
const busy = updateMut.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-slate-400 text-[13px]">
|
||||
Φόρτωση…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const loginMethod = get('security.login_method', 'password')
|
||||
const autofill = get('security.autofill_username', 'true') === 'true'
|
||||
const autoLock = get('security.auto_lock', 'false') === 'true'
|
||||
const autoLockSecs = get('security.auto_lock_seconds', '300')
|
||||
const autoLogout = get('security.auto_logout', 'false') === 'true'
|
||||
const autoLogoutSecs = get('security.auto_logout_seconds', '1800')
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* ── Μέθοδος σύνδεσης ─────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={KeyRound}
|
||||
title="Σύνδεση"
|
||||
description="Τι διαπιστευτήρια απαιτούνται όταν η εφαρμογή ξεκινά ή μετά από πλήρη αποσύνδεση."
|
||||
>
|
||||
<OptionRow
|
||||
label="Μέθοδος σύνδεσης"
|
||||
description="Πώς πιστοποιούνται οι διαχειριστές κατά την πρώτη πρόσβαση."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={loginMethod}
|
||||
onChange={v => save('security.login_method', v)}
|
||||
disabled={busy}
|
||||
options={[
|
||||
{ value: 'password', label: 'Κωδικός' },
|
||||
{ value: 'pin', label: 'PIN' },
|
||||
{ value: 'none', label: 'Κανένα' },
|
||||
]}
|
||||
/>
|
||||
</OptionRow>
|
||||
|
||||
{loginMethod === 'none' && (
|
||||
<div className="mx-5 mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[12px] text-amber-700">
|
||||
<strong>Προσοχή:</strong> με έναν μόνο λογαριασμό διαχειριστή, η εφαρμογή θα ανοίγει χωρίς πιστοποίηση.
|
||||
Χρησιμοποιήστε αυτό μόνο σε φυσικά ασφαλή, ιδιωτική συσκευή.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OptionRow
|
||||
label="Αυτόματη συμπλήρωση ονόματος χρήστη"
|
||||
description="Όταν υπάρχει μόνο ένας διαχειριστής, παράλειψη του πεδίου ονόματος."
|
||||
>
|
||||
<Toggle
|
||||
checked={autofill}
|
||||
onChange={v => save('security.autofill_username', v ? 'true' : 'false')}
|
||||
disabled={busy}
|
||||
/>
|
||||
</OptionRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Αυτόματο κλείδωμα ────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Lock}
|
||||
title="Αυτόματο Κλείδωμα"
|
||||
description="Κλειδώνει την οθόνη μετά από αδράνεια. Ξεκλειδώνεται μόνο με PIN — γρήγορο και ασφαλές."
|
||||
>
|
||||
<OptionRow label="Ενεργοποίηση αυτόματου κλειδώματος" description="Εμφάνιση οθόνης κλειδώματος PIN μετά από αδράνεια.">
|
||||
<Toggle
|
||||
checked={autoLock}
|
||||
onChange={v => save('security.auto_lock', v ? 'true' : 'false')}
|
||||
disabled={busy}
|
||||
/>
|
||||
</OptionRow>
|
||||
|
||||
{autoLock && (
|
||||
<OptionRow
|
||||
label="Κλείδωμα μετά από"
|
||||
description={`Η οθόνη κλειδώνει μετά από ${fmtSeconds(autoLockSecs)} αδράνειας.`}
|
||||
>
|
||||
<TimeSelect
|
||||
value={autoLockSecs}
|
||||
onChange={v => save('security.auto_lock_seconds', v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</OptionRow>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Αυτόματη αποσύνδεση ──────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={LogOut}
|
||||
title="Αυτόματη Αποσύνδεση"
|
||||
description="Αποσυνδέεται πλήρως μετά από αδράνεια. Απαιτεί εκ νέου πλήρη εισαγωγή διαπιστευτηρίων."
|
||||
>
|
||||
<OptionRow label="Ενεργοποίηση αυτόματης αποσύνδεσης" description="Πλήρης αποσύνδεση μετά από αδράνεια.">
|
||||
<Toggle
|
||||
checked={autoLogout}
|
||||
onChange={v => save('security.auto_logout', v ? 'true' : 'false')}
|
||||
disabled={busy}
|
||||
/>
|
||||
</OptionRow>
|
||||
|
||||
{autoLogout && (
|
||||
<OptionRow
|
||||
label="Αποσύνδεση μετά από"
|
||||
description={`Πλήρης αποσύνδεση μετά από ${fmtSeconds(autoLogoutSecs)} αδράνειας.`}
|
||||
>
|
||||
<TimeSelect
|
||||
value={autoLogoutSecs}
|
||||
onChange={v => save('security.auto_logout_seconds', v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</OptionRow>
|
||||
)}
|
||||
|
||||
{autoLock && autoLogout && parseInt(autoLogoutSecs, 10) <= parseInt(autoLockSecs, 10) && (
|
||||
<div className="mx-5 mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[12px] text-amber-700">
|
||||
Ο χρόνος αποσύνδεσης είναι μικρότερος ή ίσος με τον χρόνο κλειδώματος — η οθόνη θα αποσυνδεθεί πριν κλειδώσει.
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user