Files
xenia-pos-local/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
T
bonaminandClaude Opus 5.5 5230606144 feat(manager): show LAN waiter/manager addresses and pairing QR in App Info
App Info now lists 'Σερβιτόροι (WiFi)' http://<LAN IP> with a QR code
encoding http://<LAN IP>/?pair=<site_id> (phone camera opens the waiter app;
the native app will read the same code) and 'Διαχείριση (WiFi)'
http://<LAN IP>/manager. The LAN IP comes from HOST_IP, else from the
dashboard's own address when opened by IP; otherwise a hint to set HOST_IP.
QRModal takes an optional caption; the legacy waiter-domain QR is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:50:29 +03:00

504 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">Τραπέζια &amp; Ζώνες</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="Εισαγωγή Τραπεζιών &amp; Ζωνών"
summary={tablesSummary}
isPending={tablesImportMut.isPending}
/>
</div>
)
}
function QRModal({ url, caption = 'Σαρώστε με το κινητό για σύνδεση στο σύστημα.', 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">{caption}</p>
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
</div>
</div>
)
}
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/
// Address phones use on the LAN. HOST_IP from the server wins; otherwise, if this
// dashboard was itself opened by IP (http://192.168.x.x:8081), that IP is the server.
function resolveLanIp(status) {
if (status?.lan_ip) return status.lan_ip
const host = window.location.hostname
return IPV4.test(host) && !host.startsWith('127.') ? host : null
}
// Pairing URL: opens the waiter app when scanned with the phone camera, and the
// native app reads the same code (?pair=<site_id>) to pair with this venue.
function waiterPairUrl(lanIp, siteId) {
return `http://${lanIp}/` + (siteId ? `?pair=${encodeURIComponent(siteId)}` : '')
}
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 [qr, setQr] = useState(null) // { url, caption } | null
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>
</>
)}
{(() => {
const lanIp = resolveLanIp(status)
if (!lanIp) {
return (
<>
<div className="text-gray-500">Τοπική πρόσβαση</div>
<div className="text-xs text-amber-700">
Άγνωστη IP του server — ορίστε <span className="font-mono">HOST_IP</span> στο .env
</div>
</>
)
}
const pairUrl = waiterPairUrl(lanIp, status?.site_id)
return (
<>
<div className="text-gray-500">Σερβιτόροι (WiFi)</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono break-all">http://{lanIp}</span>
<button
onClick={() => setQr({
url: pairUrl,
caption: 'Σαρώστε με την κάμερα του κινητού — ανοίγει την εφαρμογή σερβιτόρου. Το κινητό πρέπει να είναι στο ίδιο WiFi.',
})}
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 className="text-gray-500">Διαχείριση (WiFi)</div>
<div className="font-medium text-gray-800 text-xs font-mono break-all">http://{lanIp}/manager</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={() => setQr({ url: status.waiter_domain })}
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 === 'superadmin' && (
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>
<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>
)}
{qr && <QRModal url={qr.url} caption={qr.caption} onClose={() => setQr(null)} />}
</div>
)
}