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,187 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useAuthStore from '../store/authStore'
import useShiftStore from '../store/shiftStore'
import useThemeStore from '../store/themeStore'
import client from '../api/client'
function formatTime(iso) {
if (!iso) return ''
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function formatDuration(iso) {
if (!iso) return ''
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
if (mins < 60) return `${mins}λ`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
}
export default function UserMenu() {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const ref = useRef(null)
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const { dark, toggle } = useThemeStore()
const {
shift, selfEndAllowed,
setShift, clearShift,
} = useShiftStore()
useEffect(() => {
function onClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [])
function handleLogout() {
setOpen(false)
logout()
navigate('/login')
}
const activeBreak = shift?.breaks?.find(b => !b.ended_at)
const isWaiter = user?.role === 'waiter'
async function handleEndShift() {
if (!window.confirm('Να τελειώσει η βάρδια σου;')) return
setBusy(true)
try {
await client.post('/api/shifts/end', {})
clearShift()
setOpen(false)
} catch {
// ignore — gate will re-check
} finally {
setBusy(false)
}
}
async function handleBreak() {
setBusy(true)
try {
if (activeBreak) {
await client.post(`/api/shifts/${shift.id}/break/end`)
} else {
await client.post(`/api/shifts/${shift.id}/break/start`)
}
const res = await client.get('/api/shifts/my')
setShift(res.data)
} catch {
// ignore
} finally {
setBusy(false)
}
}
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
className="icon-btn"
onClick={() => setOpen(o => !o)}
title="Μενού χρήστη"
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px' }}
>
{/* Break indicator dot */}
{activeBreak && (
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: 'var(--accent)', flexShrink: 0,
animation: 'tab-pulse 1.5s ease-in-out infinite',
}} />
)}
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{user?.username}</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="user-menu-dropdown">
{/* ── Shift info (waiters only) ─────────────────────── */}
{isWaiter && shift && (
<>
<div style={{
padding: '12px 16px',
background: 'var(--bg3)',
borderRadius: 10,
margin: '4px 8px 2px',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 4 }}>
Βάρδια ενεργή
</div>
<div style={{ fontSize: 13, color: 'var(--text)', display: 'flex', justifyContent: 'space-between' }}>
<span>Από {formatTime(shift.started_at)}</span>
<span style={{ color: 'var(--muted)' }}>{formatDuration(shift.started_at)}</span>
</div>
{shift.starting_cash != null && (
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
Αρχικά: {shift.starting_cash.toFixed(2)}
</div>
)}
{activeBreak && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 4, fontWeight: 600 }}>
Σε διάλειμμα από {formatTime(activeBreak.started_at)}
</div>
)}
</div>
{/* Break button */}
<button
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
onClick={handleBreak}
disabled={busy}
>
<span className="user-menu-item__icon">{activeBreak ? '▶' : '☕'}</span>
<span>{activeBreak ? 'Τέλος Διαλείμματος' : 'Διάλειμμα'}</span>
</button>
{/* End shift button */}
{selfEndAllowed ? (
<button
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
onClick={handleEndShift}
disabled={busy}
style={{ color: 'var(--danger)' }}
>
<span className="user-menu-item__icon"></span>
<span>Τέλος Βάρδιας</span>
</button>
) : (
<div style={{ padding: '8px 16px', fontSize: 12, color: 'var(--muted)', fontStyle: 'italic' }}>
Ζητήστε από τον διαχειριστή να κλείσει τη βάρδια
</div>
)}
<div className="user-menu-divider" />
</>
)}
{/* ── Theme toggle ──────────────────────────────────── */}
<button className="user-menu-item" onClick={() => { toggle(); setOpen(false) }}>
<span className="user-menu-item__icon">{dark ? '☀️' : '🌙'}</span>
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
</button>
{/* ── Settings ──────────────────────────────────────── */}
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/settings') }}>
<span className="user-menu-item__icon"></span>
<span>Ρυθμίσεις</span>
</button>
<div className="user-menu-divider" />
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
<span className="user-menu-item__icon"></span>
<span>Αποσύνδεση</span>
</button>
</div>
)}
</div>
)
}