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:
302
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
302
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, createContext, useContext } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Lock, AlertTriangle, ShieldAlert } from 'lucide-react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
import UserMenuButton from '../components/UserMenuButton'
|
||||
import EditProfileModal from '../components/EditProfileModal'
|
||||
import useLicenseStatus from '../hooks/useLicenseStatus'
|
||||
|
||||
export const LicenseContext = createContext(null)
|
||||
|
||||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||
|
||||
// ─── License Banner ───────────────────────────────────────────────────────────
|
||||
|
||||
function LicenseBanner({ license }) {
|
||||
const { lock_reason, locked, lock_pending, days_until_expiry, expires_at,
|
||||
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked } = license
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
// Admin lock (deferred or enforced)
|
||||
if (lock_reason === 'admin') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
{locked
|
||||
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
|
||||
: 'Εκκρεμεί κλείδωμα από διαχειριστή — θα ενεργοποιηθεί μετά το κλείσιμο της τρέχουσας ημέρας.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// License fully expired + grace over → blocked
|
||||
if (isBlocked && lock_reason === 'expired') {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// In grace period (expired but still allowed to operate)
|
||||
if (inGracePeriod) {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
const remaining = grace_days_remaining ?? '?'
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Expiry warning (≤5 days remaining)
|
||||
if (showExpiryWarning) {
|
||||
const days = days_until_expiry
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Lock Screen — PIN only, always. No password ever. ────────────────────────
|
||||
|
||||
function LockScreen({ username, displayName, onUnlock, onLogout }) {
|
||||
const [pin, setPin] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function pressDigit(d) {
|
||||
if (loading) return
|
||||
if (d === '⌫') { setPin(p => p.slice(0, -1)); setError(''); return }
|
||||
if (d === '') return
|
||||
if (pin.length >= 6) return
|
||||
setPin(p => p + d)
|
||||
}
|
||||
|
||||
async function submitPin(usedPin) {
|
||||
if (usedPin.length < 4) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/auth/login', { username, pin: usedPin })
|
||||
if (data.user.role !== 'manager' && data.user.role !== 'sysadmin') {
|
||||
setError('Not a manager account.')
|
||||
setPin('')
|
||||
return
|
||||
}
|
||||
onUnlock(data.user, data.access_token)
|
||||
} catch {
|
||||
setError('Wrong PIN')
|
||||
setPin('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pin.length === 4) submitPin(pin)
|
||||
}, [pin])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e) {
|
||||
if (loading) return
|
||||
if (e.key >= '0' && e.key <= '9') pressDigit(e.key)
|
||||
else if (e.key === 'Backspace') pressDigit('⌫')
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [pin, loading])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-xs text-center space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||
<Lock className="h-6 w-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-slate-900">Locked</p>
|
||||
<p className="text-[13px] text-slate-500">{displayName}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-center gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`w-3.5 h-3.5 rounded-full border-2 transition-colors ${
|
||||
i < pin.length ? 'bg-sky-500 border-sky-500' : 'border-slate-300'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{DIGITS.map((d, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => pressDigit(d)}
|
||||
disabled={d === '' || loading}
|
||||
className={`h-12 rounded-xl text-lg font-semibold transition-colors ${
|
||||
d === '' ? 'invisible'
|
||||
: d === '⌫' ? 'bg-slate-100 hover:bg-slate-200 text-slate-600'
|
||||
: 'bg-slate-100 hover:bg-sky-100 active:bg-sky-200 text-slate-800'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-rose-500">{error}</p>}
|
||||
{loading && <p className="text-[12px] text-slate-400">Verifying…</p>}
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-[12px] text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
Sign out instead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── AppLayout ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user, savedUsername, logout, lock, unlock, locked } = useAuthStore()
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const license = useLicenseStatus()
|
||||
|
||||
const { data: securitySettings = null } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
// Single ref object — updated every render so the interval always sees fresh values
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { user, locked, securitySettings, logout, lock, navigate }
|
||||
|
||||
const lastActivityRef = useRef(Date.now())
|
||||
|
||||
// ── Clock ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Single long-lived interval — never restarts ────────────────────────────
|
||||
useEffect(() => {
|
||||
function onActivity() { lastActivityRef.current = Date.now() }
|
||||
|
||||
const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click']
|
||||
EVENTS.forEach(ev => window.addEventListener(ev, onActivity, { passive: true }))
|
||||
|
||||
const id = setInterval(() => {
|
||||
const { user, locked, securitySettings, logout, lock, navigate } = stateRef.current
|
||||
const idle = (Date.now() - lastActivityRef.current) / 1000
|
||||
if (!user || !securitySettings) return
|
||||
|
||||
const get = (key, fb) => securitySettings?.[key]?.value ?? fb
|
||||
const autoLock = get('security.auto_lock', 'false') === 'true'
|
||||
const autoLogout = get('security.auto_logout', 'false') === 'true'
|
||||
if (!autoLock && !autoLogout) return
|
||||
|
||||
const lockSecs = autoLock ? parseInt(get('security.auto_lock_seconds', '300'), 10) : Infinity
|
||||
const logoutSecs = autoLogout ? parseInt(get('security.auto_logout_seconds', '1800'), 10) : Infinity
|
||||
|
||||
// Auto-logout runs regardless of lock state
|
||||
if (idle >= logoutSecs) {
|
||||
logout()
|
||||
navigate('/login', { replace: true, state: { manualLogout: true } })
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-lock only fires when not already locked
|
||||
if (!locked && idle >= lockSecs) {
|
||||
lock()
|
||||
}
|
||||
}, 1_000)
|
||||
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
EVENTS.forEach(ev => window.removeEventListener(ev, onActivity))
|
||||
}
|
||||
}, []) // runs once on mount, reads everything from stateRef
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login', { replace: true, state: { manualLogout: true } })
|
||||
}
|
||||
|
||||
function handleUnlock(u, t) {
|
||||
unlock(u, t)
|
||||
lastActivityRef.current = Date.now()
|
||||
}
|
||||
|
||||
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
const loginUsername = user?.username || savedUsername || ''
|
||||
const displayName = user?.full_name || loginUsername
|
||||
|
||||
return (
|
||||
<LicenseContext.Provider value={license}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{locked && loginUsername && (
|
||||
<LockScreen
|
||||
username={loginUsername}
|
||||
displayName={displayName || loginUsername}
|
||||
onUnlock={handleUnlock}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<LicenseBanner license={license} />
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0">
|
||||
<span className="text-[13px] font-semibold text-slate-600 tabular-nums">{timeStr}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={lock}
|
||||
title="Lock"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<UserMenuButton
|
||||
displayName={displayName}
|
||||
onEditProfile={() => setProfileOpen(true)}
|
||||
onSignOut={handleLogout}
|
||||
/>
|
||||
{profileOpen && <EditProfileModal onClose={() => setProfileOpen(false)} />}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</LicenseContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user