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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user