Files
xenia-pos-local/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

459 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 === '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>
)}
{qrOpen && status?.waiter_domain && (
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
)}
</div>
)
}