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:
187
waiter_pwa/src/components/UserMenu.jsx
Normal file
187
waiter_pwa/src/components/UserMenu.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user