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:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

View 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>
)
}