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>
This commit is contained in:
2026-09-28 11:50:29 +03:00
co-authored by Claude Opus 5.5
parent 2d47530069
commit 5230606144
@@ -302,7 +302,7 @@ function DataTransferSection() {
} }
function QRModal({ url, onClose }) { function QRModal({ url, caption = 'Σαρώστε με το κινητό για σύνδεση στο σύστημα.', onClose }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div <div
@@ -314,15 +314,29 @@ function QRModal({ url, onClose }) {
<div className="p-3 bg-white border border-gray-200 rounded-xl"> <div className="p-3 bg-white border border-gray-200 rounded-xl">
<QRCodeSVG value={url} size={220} includeMargin={false} /> <QRCodeSVG value={url} size={220} includeMargin={false} />
</div> </div>
<p className="text-xs text-gray-400 text-center"> <p className="text-xs text-gray-400 text-center">{caption}</p>
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
</p>
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button> <button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
</div> </div>
</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) { function formatUptime(seconds) {
const h = Math.floor(seconds / 3600) const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60) const m = Math.floor((seconds % 3600) / 60)
@@ -334,7 +348,7 @@ export default function AppInfoTab() {
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
const qc = useQueryClient() const qc = useQueryClient()
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [qrOpen, setQrOpen] = useState(false) const [qr, setQr] = useState(null) // { url, caption } | null
const { data: status, isLoading } = useQuery({ const { data: status, isLoading } = useQuery({
queryKey: ['system-status'], queryKey: ['system-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data), queryFn: () => client.get('/api/system/status').then(r => r.data),
@@ -414,13 +428,46 @@ export default function AppInfoTab() {
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</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 && ( {status?.waiter_domain && (
<> <>
<div className="text-gray-500">Waiter Domain</div> <div className="text-gray-500">Waiter Domain</div>
<div className="flex items-center gap-2 flex-wrap"> <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> <span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
<button <button
onClick={() => setQrOpen(true)} 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" 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 QR Code
@@ -450,9 +497,7 @@ export default function AppInfoTab() {
</div> </div>
)} )}
{qrOpen && status?.waiter_domain && ( {qr && <QRModal url={qr.url} caption={qr.caption} onClose={() => setQr(null)} />}
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
)}
</div> </div>
) )
} }