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,3 @@
node_modules
dist
.env

View File

@@ -0,0 +1,18 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="el">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POS Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600;
}
location /static/ {
proxy_pass http://backend:8000/static/;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}

3564
manager_dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
{
"name": "manager-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"lucide-react": "^1.14.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.28.0",
"recharts": "^3.8.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import useAuthStore from './store/authStore'
import AppLayout from './layouts/AppLayout'
import LoginPage from './pages/LoginPage'
import SetupWizard from './pages/SetupWizard'
import OperationsPage from './pages/OperationsPage'
import TablesPage from './pages/TablesPage'
import OrderDetailPage from './pages/OrderDetailPage'
import ManagementPage from './pages/ManagementPage'
import ReportsPage from './pages/reports/ReportsPage'
import SettingsPage from './pages/Settings/SettingsPage'
import client from './api/client'
function Spinner() {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
)
}
// Rehydrates user from stored token before rendering any routes.
// Prevents the flicker where a valid token causes a redirect to /login on refresh.
function AuthRehydrator({ children }) {
const { token, user, rehydrate, logout } = useAuthStore()
const [ready, setReady] = useState(false)
useEffect(() => {
if (token && !user) {
client.get('/api/auth/me')
.then(r => { rehydrate(r.data, token) })
.catch(() => { logout() })
.finally(() => setReady(true))
} else {
setReady(true)
}
}, []) // intentionally runs once on mount
if (!ready) return <Spinner />
return children
}
function RequireAuth({ children }) {
const token = useAuthStore(s => s.token)
return token ? children : <Navigate to="/login" replace />
}
// Checks /api/setup/status on mount and redirects to /setup if no managers exist.
function SetupGuard({ children }) {
const [checked, setChecked] = useState(false)
const navigate = useNavigate()
useEffect(() => {
client.get('/api/setup/status')
.then(({ data }) => {
if (data.needs_setup) navigate('/setup', { replace: true })
})
.catch(() => {
// Backend unreachable — proceed, login will surface the error.
})
.finally(() => setChecked(true))
}, [navigate])
if (!checked) return <Spinner />
return children
}
export default function App() {
return (
<BrowserRouter>
<AuthRehydrator>
<Routes>
<Route path="/setup" element={<SetupWizard />} />
<Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} />
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route index element={<Navigate to="/operations" replace />} />
<Route path="dashboard" element={<Navigate to="/operations" replace />} />
<Route path="operations" element={<OperationsPage />} />
<Route path="tables" element={<TablesPage />} />
<Route path="orders/:orderId" element={<OrderDetailPage />} />
<Route path="management" element={<ManagementPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
</AuthRehydrator>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,25 @@
import axios from 'axios'
const client = axios.create({ baseURL: '' })
client.interceptors.request.use(config => {
const token = localStorage.getItem('manager_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
client.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
// On hard 401 (expired/invalid token) force a full logout
localStorage.removeItem('manager_token')
localStorage.removeItem('manager_username')
localStorage.removeItem('manager_lock_timeout')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default client

View File

@@ -0,0 +1,14 @@
export default function ConfirmModal({ title, message, confirmLabel = 'Επιβεβαίωση', confirmClass = 'btn-danger', onConfirm, onCancel }) {
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
{message && <p className="text-gray-600 text-sm">{message}</p>}
<div className="flex gap-3 pt-2">
<button onClick={onCancel} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={onConfirm} className={`flex-1 btn ${confirmClass}`}>{confirmLabel}</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
/**
* DateInput / DateTimeInput
*
* Native date pickers display in the OS/browser locale (MM/DD/YYYY on en-US).
* These wrappers overlay the native input with a visible DD/MM/YYYY display
* while keeping the full native picker UX (click, keyboard, mobile wheel).
*
* Props mirror a plain <input>: value (YYYY-MM-DD or YYYY-MM-DDTHH:MM),
* onChange (receives the same synthetic event), className.
*/
import { useRef } from 'react'
function formatDateGR(value) {
// value is "YYYY-MM-DD"
if (!value) return ''
const [y, m, d] = value.split('-')
if (!y || !m || !d) return value
return `${d}/${m}/${y}`
}
function formatDateTimeGR(value) {
// value is "YYYY-MM-DDTHH:MM"
if (!value) return ''
const [datePart, timePart] = value.split('T')
if (!datePart) return value
const [y, m, d] = datePart.split('-')
if (!y || !m || !d) return value
return `${d}/${m}/${y}${timePart ? ' ' + timePart : ''}`
}
export function DateInput({ value, onChange, className = '', ...rest }) {
const ref = useRef(null)
return (
<div
className={`relative cursor-pointer ${className}`}
onClick={() => ref.current?.showPicker?.()}
>
{/* Visible display */}
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
{value ? formatDateGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>}
</div>
{/* Native input — invisible but functional (provides the picker) */}
<input
ref={ref}
type="date"
value={value}
onChange={onChange}
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
tabIndex={0}
{...rest}
/>
</div>
)
}
export function DateTimeInput({ value, onChange, className = '', ...rest }) {
const ref = useRef(null)
return (
<div
className={`relative cursor-pointer ${className}`}
onClick={() => ref.current?.showPicker?.()}
>
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
{value ? formatDateTimeGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ</span>}
</div>
<input
ref={ref}
type="datetime-local"
value={value}
onChange={onChange}
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
tabIndex={0}
{...rest}
/>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react'
import Modal from '../ui/Modal'
import Button from '../ui/Button'
import { LabelledInput } from '../ui/Input'
import client from '../api/client'
import useAuthStore from '../store/authStore'
export default function EditProfileModal({ onClose }) {
const { user, updateUser } = useAuthStore()
const [fullName, setFullName] = useState(user?.full_name || '')
const [username, setUsername] = useState(user?.username || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [newPin, setNewPin] = useState('')
const [confirmPin, setConfirmPin] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const passwordTouched = currentPassword || newPassword || confirmPassword
const pinTouched = newPin || confirmPin
function validate() {
if (passwordTouched) {
if (!currentPassword) return 'Enter your current password.'
if (!newPassword) return 'Enter a new password.'
if (newPassword.length < 6) return 'New password must be at least 6 characters.'
if (newPassword !== confirmPassword) return 'New passwords do not match.'
}
if (pinTouched) {
if (newPin.length < 4) return 'PIN must be 4 digits.'
if (newPin !== confirmPin) return 'PINs do not match.'
}
if (!username.trim()) return 'Username cannot be empty.'
return null
}
async function handleSave() {
const validationError = validate()
if (validationError) { setError(validationError); return }
const body = {}
if (fullName !== (user?.full_name || '')) body.full_name = fullName
if (username !== user?.username) body.username = username
if (passwordTouched) {
body.current_password = currentPassword
body.new_password = newPassword
}
if (pinTouched) {
body.new_pin = newPin
}
if (Object.keys(body).length === 0) { onClose(); return }
setError('')
setSaving(true)
try {
const { data } = await client.patch('/api/auth/me', body)
updateUser(data)
onClose()
} catch (err) {
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Failed to save changes.')
} finally {
setSaving(false)
}
}
function onlyDigits(val, max) {
return val.replace(/\D/g, '').slice(0, max)
}
return (
<Modal title="Edit Profile" onClose={onClose} maxWidth="max-w-lg">
<div className="space-y-4">
<div className="space-y-2">
<LabelledInput
label="Display Name"
value={fullName}
onChange={e => setFullName(e.target.value)}
placeholder="e.g. Maria Papadopoulou"
/>
<LabelledInput
label="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="border-t border-slate-100" />
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Change Password <span className="normal-case font-normal">(leave blank to keep current)</span>
</p>
<LabelledInput
label="Current Password"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
<LabelledInput
label="New Password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
<LabelledInput
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="border-t border-slate-100" />
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Change PIN <span className="normal-case font-normal">(leave blank to keep current)</span>
</p>
<div className="flex gap-2">
<LabelledInput
label="New PIN"
type="password"
inputMode="numeric"
value={newPin}
onChange={e => setNewPin(onlyDigits(e.target.value, 4))}
placeholder="••••"
className="flex-1"
/>
<LabelledInput
label="Confirm PIN"
type="password"
inputMode="numeric"
value={confirmPin}
onChange={e => setConfirmPin(onlyDigits(e.target.value, 4))}
placeholder="••••"
className="flex-1"
/>
</div>
</div>
{error && (
<p className="text-[12px] text-rose-500">{error}</p>
)}
</div>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Changes'}
</Button>
</Modal.Footer>
</Modal>
)
}

View File

@@ -0,0 +1,47 @@
import { NavLink } from 'react-router-dom'
import { useState } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
const NAV = [
{ to: '/operations', icon: BarChart2, label: 'Διοίκηση' },
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
{ to: '/management', icon: Package, label: 'Διαχείριση' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
]
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
return (
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
{/* Logo / collapse toggle */}
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
{!collapsed && <span className="font-bold text-lg tracking-wide">POS</span>}
<button
onClick={() => setCollapsed(c => !c)}
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"
aria-label="Toggle sidebar"
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
<nav className="flex-1 py-4 space-y-1 px-2">
{NAV.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-3 rounded-lg font-medium transition-colors min-h-[44px] ` +
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
}
>
<Icon size={20} className="shrink-0" />
{!collapsed && <span className="text-sm">{label}</span>}
</NavLink>
))}
</nav>
</aside>
)
}

View File

@@ -0,0 +1,18 @@
const MAP = {
free: { label: 'Ελεύθερο', cls: 'bg-gray-100 text-gray-600' },
open: { label: 'Ανοιχτό', cls: 'bg-green-100 text-green-700' },
partially_paid: { label: 'Μερική πληρωμή', cls: 'bg-amber-100 text-amber-700' },
paid: { label: 'Πληρώθηκε', cls: 'bg-blue-100 text-blue-700' },
closed: { label: 'Κλειστό', cls: 'bg-gray-200 text-gray-500' },
cancelled: { label: 'Ακυρώθηκε', cls: 'bg-red-100 text-red-600' },
active: { label: 'Ενεργό', cls: 'bg-green-100 text-green-700' },
}
export default function StatusBadge({ status }) {
const { label, cls } = MAP[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' }
return (
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>
{label}
</span>
)
}

View File

@@ -0,0 +1,56 @@
import { useState, useEffect, useRef } from 'react'
import { ChevronDown, User, LogOut } from 'lucide-react'
export default function UserMenuButton({ displayName, onEditProfile, onSignOut }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
function handleOutside(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
function handleEscape(e) {
if (e.key === 'Escape') setOpen(false)
}
if (open) {
document.addEventListener('mousedown', handleOutside)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('mousedown', handleOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [open])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(o => !o)}
className="flex items-center gap-1 text-[13px] text-slate-600 font-medium hover:text-slate-900 transition-colors"
>
{displayName}
<ChevronDown className={`h-3.5 w-3.5 text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-xl border border-slate-200 bg-white shadow-lg py-1 z-50">
<button
onClick={() => { setOpen(false); onEditProfile() }}
className="flex w-full items-center gap-2.5 px-3.5 py-2 text-[13px] text-slate-700 hover:bg-slate-50 transition-colors"
>
<User className="h-3.5 w-3.5 text-slate-400" />
Edit Profile
</button>
<div className="my-1 border-t border-slate-100" />
<button
onClick={() => { setOpen(false); onSignOut() }}
className="flex w-full items-center gap-2.5 px-3.5 py-2 text-[13px] text-rose-500 hover:bg-rose-50 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
Sign Out
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useQuery } from '@tanstack/react-query'
import client from '../api/client'
/**
* Polls /api/system/status every 5 minutes to stay in sync with the backend
* heartbeat cycle. Returns a stable object so callers can destructure safely.
*
* Returned shape:
* licensed bool
* locked bool
* lock_pending bool
* lock_reason "admin" | "expired" | null
* expires_at string | null (ISO)
* days_until_expiry number | null (negative = expired)
* grace_expires_at string | null (ISO)
* grace_days_remaining number | null
* sync_failed bool
*
* Derived helpers:
* showExpiryWarning bool — true when 0 < days_until_expiry <= 5
* inGracePeriod bool — true when expired but grace not yet over
* isBlocked bool — locked=true OR (unlicensed AND grace over)
*/
export default function useLicenseStatus() {
const { data } = useQuery({
queryKey: ['license-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data),
staleTime: 0,
refetchInterval: 5 * 60 * 1000,
refetchIntervalInBackground: true,
})
if (!data) {
return {
licensed: true,
locked: false,
lock_pending: false,
lock_reason: null,
expires_at: null,
days_until_expiry: null,
grace_expires_at: null,
grace_days_remaining: null,
sync_failed: false,
showExpiryWarning: false,
inGracePeriod: false,
isBlocked: false,
}
}
const {
licensed = true,
locked = false,
lock_pending = false,
lock_reason = null,
expires_at = null,
days_until_expiry = null,
grace_expires_at = null,
grace_days_remaining = null,
sync_failed = false,
} = data
const showExpiryWarning =
days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 5
const inGracePeriod =
days_until_expiry != null && days_until_expiry < 0 && licensed
const isBlocked = locked || !licensed
return {
licensed,
locked,
lock_pending,
lock_reason,
expires_at,
days_until_expiry,
grace_expires_at,
grace_days_remaining,
sync_failed,
showExpiryWarning,
inGracePeriod,
isBlocked,
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C21.5093 4.43821 21.8356 5.80655 21.9449 8" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 11V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 11V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 7H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 10L14 6M18 10L21 7L17 3L14 6M18 10L17 11M14 6L8 12V16H12L14.5 13.5M20 14V20H12M10 4L4 4L4 20H7" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 10.99L13.13 14.05C12.9858 14.2058 12.811 14.3298 12.6166 14.4148C12.4221 14.4998 12.2122 14.5437 12 14.5437C11.7878 14.5437 11.5779 14.4998 11.3834 14.4148C11.189 14.3298 11.0142 14.2058 10.87 14.05L8 10.99" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.8599L10.87 10.8C11.0125 10.6416 11.1868 10.5149 11.3815 10.4282C11.5761 10.3415 11.7869 10.2966 12 10.2966C12.2131 10.2966 12.4239 10.3415 12.6185 10.4282C12.8132 10.5149 12.9875 10.6416 13.13 10.8L16 13.8599" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 797 B

View File

@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-50 text-slate-900 text-base antialiased;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2.5 rounded-xl font-semibold text-sm transition-colors min-h-[44px] disabled:opacity-40 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-700 hover:bg-primary-800 text-white;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 text-gray-700;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white;
}
.btn-ghost {
@apply bg-transparent hover:bg-gray-100 text-gray-600;
}
.card {
@apply bg-white rounded-2xl shadow-sm border border-gray-100;
}
.input {
@apply w-full border border-gray-300 rounded-xl px-4 py-2.5 text-base focus:outline-none focus:ring-2 focus:ring-primary-600 disabled:bg-gray-50;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
}

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

View File

@@ -0,0 +1,21 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'react-hot-toast'
import App from './App.jsx'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 10_000 },
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</QueryClientProvider>
</React.StrictMode>
)

View File

@@ -0,0 +1,788 @@
import { useState, useContext } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
import Button from '../ui/Button'
import { LicenseContext } from '../layouts/AppLayout'
// ─── Business Day + Shift Management Panel ───────────────────────────────────
function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function fmtShiftDuration(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}λ`
}
function StartShiftModal({ waiters, onClose, onStart }) {
const [waiterId, setWaiterId] = useState('')
const [cash, setCash] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return }
setBusy(true)
try {
await onStart(Number(waiterId), cash ? parseFloat(cash) : null)
onClose()
} catch (e) {
toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Έναρξη Βάρδιας</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
<option value=""> Επιλέξτε </option>
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
</select>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά ()</label>
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => setCash(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
</div>
<div className="flex gap-3 pt-1">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
<button onClick={submit} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
</button>
</div>
</div>
</div>
)
}
function CloseConfirmModal({ details, onClose, onConfirm, busy }) {
const hasPendingPayments = details.partially_paid > 0
if (!hasPendingPayments) {
// All tables open but nothing owed — safe to close, just needs confirmation
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
<p className="font-semibold">
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
</p>
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
Ακύρωση
</button>
<button onClick={onConfirm} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
</button>
</div>
</div>
</div>
)
}
// Some tables have unpaid items — revenue will be lost, needs hard warning
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="text-red-600 text-lg font-bold">!</span>
</div>
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
<p className="font-semibold">
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
</p>
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
</div>
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
Ακύρωση
</button>
<button onClick={onConfirm} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
</button>
</div>
</div>
</div>
)
}
function BusinessDayPanel() {
const qc = useQueryClient()
const [showStartShift, setShowStartShift] = useState(false)
const [closeDetails, setCloseDetails] = useState(null)
const [forceClosing, setForceClosing] = useState(false)
const [licenseBlock, setLicenseBlock] = useState(null)
const license = useContext(LicenseContext)
const { data: businessDay } = useQuery({
queryKey: ['business-day'],
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
refetchInterval: 15_000,
})
const { data: activeShifts = [] } = useQuery({
queryKey: ['active-shifts'],
queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
refetchInterval: 15_000,
})
const { data: allWaiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const waitersWithoutShift = allWaiters.filter(
w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
)
const openDayMut = useMutation({
mutationFn: () => client.post('/api/business-day/open', {}),
onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
onError: (e) => {
const detail = e.response?.data?.detail
if (detail?.code === 'SYSTEM_LOCKED' || detail?.code === 'LICENSE_EXPIRED') {
setLicenseBlock({ code: detail.code, message: detail.message })
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα')
}
},
})
function handleOpenDay() {
if (license?.isBlocked) {
setLicenseBlock({
code: license.lock_reason === 'admin' ? 'SYSTEM_LOCKED' : 'LICENSE_EXPIRED',
message: license.lock_reason === 'admin'
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
: 'Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.',
})
return
}
openDayMut.mutate()
}
async function handleCloseDay(force = false) {
setForceClosing(force)
try {
await client.post('/api/business-day/close', { force })
toast.success('Ημέρα έκλεισε!')
setCloseDetails(null)
qc.invalidateQueries({ queryKey: ['business-day'] })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
} catch (e) {
const detail = e.response?.data?.detail
if (e.response?.status === 409 && detail?.open_orders) {
setCloseDetails(detail)
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
}
} finally {
setForceClosing(false)
}
}
async function handleEndShift(shiftId, waiterName) {
if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return
try {
await client.post(`/api/shifts/manager/end/${shiftId}`, {})
toast.success('Βάρδια έκλεισε')
qc.invalidateQueries({ queryKey: ['active-shifts'] })
} catch (e) {
toast.error(e.response?.data?.detail || 'Σφάλμα')
}
}
async function handleStartShift(waiterId, startingCash) {
await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash })
toast.success('Βάρδια ξεκίνησε!')
qc.invalidateQueries({ queryKey: ['active-shifts'] })
}
const isOpen = !!businessDay
return (
<>
<div className="rounded-2xl border overflow-hidden"
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
{/* Header row */}
<div className="flex items-center justify-between px-5 py-3"
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
<div className="flex items-center gap-3">
<div style={{
width: 10, height: 10, borderRadius: '50%',
background: isOpen ? '#16a34a' : '#9ca3af',
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
}} />
<div>
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
</span>
{isOpen && businessDay?.opened_at && (
<span className="text-xs text-gray-500 ml-2">
από {fmtTime(businessDay.opened_at)}
</span>
)}
</div>
</div>
<div className="flex gap-2">
{isOpen && waitersWithoutShift.length > 0 && (
<button
onClick={() => setShowStartShift(true)}
className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
>
+ Βάρδια
</button>
)}
{isOpen ? (
<button
onClick={() => handleCloseDay(false)}
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
>
Κλείσιμο Ημέρας
</button>
) : (
<button
onClick={handleOpenDay}
disabled={openDayMut.isPending}
className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
>
{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
</button>
)}
</div>
</div>
{/* Active shifts */}
{isOpen && (
<div className="px-5 py-3 border-t border-gray-100 bg-white">
{activeShifts.length === 0 ? (
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
) : (
<div className="flex flex-wrap gap-2">
{activeShifts.map(s => (
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
<div>
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
{s.total_collected > 0 && (
<span className="text-xs text-green-700 ml-2 font-medium">{s.total_collected.toFixed(2)}</span>
)}
</div>
<button
onClick={() => handleEndShift(s.id, s.waiter_name)}
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
title="Τέλος βάρδιας"
>
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{showStartShift && (
<StartShiftModal
waiters={waitersWithoutShift}
onClose={() => setShowStartShift(false)}
onStart={handleStartShift}
/>
)}
{closeDetails && (
<CloseConfirmModal
details={closeDetails}
onClose={() => setCloseDetails(null)}
onConfirm={() => handleCloseDay(true)}
busy={forceClosing}
/>
)}
{licenseBlock && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm text-center space-y-4">
<div className="flex justify-center">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
licenseBlock.code === 'SYSTEM_LOCKED' ? 'bg-red-100' : 'bg-orange-100'
}`}>
<span className="text-2xl">{licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'}</span>
</div>
</div>
<h2 className="text-[15px] font-bold text-slate-900">
{licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'}
</h2>
<p className="text-[13px] text-slate-600">{licenseBlock.message}</p>
<button
onClick={() => setLicenseBlock(null)}
className="w-full h-10 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[13px] font-semibold transition-colors"
>
Κλείσιμο
</button>
</div>
</div>
)}
</>
)
}
const FILTERS = ['all', 'open', 'partially_paid', 'free']
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
// ─── Design tokens ────────────────────────────────────────────────────────────
const COLORS = {
open: {
label: 'Ανοιχτό',
tint: '#eef7f0', tintStrong: '#d7ecdc',
accent: '#2f9e5e', ink: '#1f7042',
},
partially_paid: {
label: 'Μερική πληρ.',
tint: '#f4eefb', tintStrong: '#e3d4f3',
accent: '#7a44c9', ink: '#57309a',
},
free: {
label: 'Ελεύθερο',
tint: '#f4f4f2', tintStrong: '#dfe2e6',
accent: '#8a9099', ink: '#5a6169',
},
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatEuro(n) {
return '€' + parseFloat(n).toFixed(2)
}
function formatDuration(openedAt) {
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
if (mins < 60) return `${mins}m`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}h` : `${h}h ${m}m`
}
function occupiedMinsFromDate(openedAt) {
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
}
function orderTotal(items = []) {
return items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
}
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return palette[h % palette.length]
}
function WaiterBubble({ waiter, size = 26 }) {
// waiter: { name, avatarUrl }
if (waiter.avatarUrl) {
return (
<img
src={waiter.avatarUrl}
alt={waiter.name}
style={{
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
}}
/>
)
}
const parts = waiter.name.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(waiter.name),
color: 'white',
fontSize: size * 0.42,
fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
)
}
// ─── V1 Table Card ────────────────────────────────────────────────────────────
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, onClick }) {
const s = COLORS[status] || COLORS.free
const [hover, setHover] = useState(false)
const [pressed, setPressed] = useState(false)
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
const showMulti = waiters.length >= 3
return (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false) }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: '100%', minWidth: 330, height: 200,
padding: '16px 18px 16px 24px',
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 14,
boxShadow: pressed
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
: hover
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
flexShrink: 0,
}}
>
{/* left accent bar */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent,
borderRadius: '14px 0 0 14px',
}} />
{/* Header: name + status pill */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 34, fontWeight: 700, lineHeight: 1,
letterSpacing: -0.5,
color: '#111315',
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px',
borderRadius: 999,
background: s.accent,
color: 'white',
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
{/* Flags row */}
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
{hasPendingPrint && (
<span style={{
fontSize: 11, fontWeight: 700,
background: '#92400e', color: '#fcd34d',
borderRadius: 999, padding: '2px 8px',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
Εκκρεμής εκτύπωση
</span>
)}
</div>
{/* Stats row */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
alignItems: 'end',
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<WaiterBubble waiter={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: '#2b2f33',
background: 'white', border: '1px solid #dfe2e6',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<WaiterBubble waiter={w} size={24} />
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
</div>
))
)}
</div>
</button>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [filter, setFilter] = useState('all')
const [retryingId, setRetryingId] = useState(null)
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: tables = [], isLoading: tablesLoading } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
refetchInterval: 5_000,
})
const { data: orders = [], isLoading: ordersLoading } = useQuery({
queryKey: ['orders-active'],
queryFn: () => client.get('/api/orders/').then(r => r.data),
refetchInterval: 5_000,
})
const { data: waiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
// waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
const waiterMap = Object.fromEntries(waiters.map(w => {
const name = w.full_name || w.nickname || w.username
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
const avatarUrl = w.avatar_url ?? null
return [w.id, { name, shortName, avatarUrl }]
}))
const tableCards = tables.map(table => {
const order = orders.find(o =>
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
)
const tableStatus = order ? order.status : 'free'
const hasPendingPrint = order
? order.items.some(i => i.status === 'active' && !i.printed)
: false
return { table, order, tableStatus, hasPendingPrint }
})
const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
async function retrySingleOrder(orderId) {
setRetryingId(orderId)
try {
const res = await client.post(`/api/orders/${orderId}/retry-print`)
const results = res.data.print_results ?? []
const allOk = results.length === 0 || results.every(r => r.success)
if (allOk) {
toast.success('Εκτυπώθηκε επιτυχώς')
} else {
const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
toast.error(`Αποτυχία: ${failed}`)
}
queryClient.invalidateQueries({ queryKey: ['orders-active'] })
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setRetryingId(null)
}
}
async function retryAllOrders() {
for (const { order } of pendingPrintOrders) {
if (order) await retrySingleOrder(order.id)
}
}
const filtered = filter === 'all'
? tableCards
: tableCards.filter(c => c.tableStatus === filter)
if (tablesLoading || ordersLoading) {
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
}
return (
<div className="overflow-y-auto h-full p-6 space-y-6">
<BusinessDayPanel />
<div className="flex items-center justify-end">
<div className="flex gap-2">
{FILTERS.map(f => (
<Button
key={f}
size="sm"
variant={filter === f ? 'primary' : 'secondary'}
onClick={() => setFilter(f)}
>
{FILTER_LABELS[f]}
</Button>
))}
</div>
</div>
{filtered.length === 0 && (
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
{filtered.map(({ table, order, tableStatus, hasPendingPrint }) => {
const waiterNames = order
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
: []
const amount = order ? orderTotal(order.items) : null
return (
<TableCardV1
key={table.id}
name={table.label || `T${table.number}`}
status={tableStatus}
amount={amount}
openedAt={order?.opened_at ?? null}
waiters={waiterNames}
hasPendingPrint={hasPendingPrint}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
/>
)
})}
</div>
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
{pendingPrintOrders.length > 0 && (
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
<div className="flex items-center gap-3">
<span style={{ fontSize: 20 }}></span>
<div>
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
<p className="text-xs text-orange-700 mt-0.5">
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
</p>
</div>
</div>
<Button
size="sm"
variant="primary"
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
onClick={retryAllOrders}
disabled={retryingId !== null}
>
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
</Button>
</div>
<div className="divide-y divide-orange-50">
{pendingPrintOrders.map(({ table, order }) => {
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
const tableName = table.label || `T${table.number}`
return (
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
{tableName}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-800">
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
</p>
<p className="text-xs text-gray-500 truncate">
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/orders/${order.id}`)}
>
Λεπτομέρειες
</Button>
<Button
size="sm"
variant="primary"
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
onClick={() => retrySingleOrder(order.id)}
disabled={retryingId === order.id}
>
{retryingId === order.id ? '…' : 'Εκτύπωση'}
</Button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,344 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { User, Lock, KeyRound } from 'lucide-react'
import useAuthStore from '../store/authStore'
import client from '../api/client'
import Button from '../ui/Button'
import { LabelledInput } from '../ui/Input'
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
function PinDots({ length, filled }) {
return (
<div className="flex justify-center gap-3 py-2">
{Array.from({ length }).map((_, i) => (
<div key={i} className={`w-3.5 h-3.5 rounded-full border-2 transition-colors ${
i < filled ? 'bg-sky-500 border-sky-500' : 'border-slate-300'
}`} />
))}
</div>
)
}
function PinPad({ pin, onChange, disabled }) {
function press(d) {
if (disabled) return
if (d === '⌫') { onChange(p => p.slice(0, -1)); return }
if (d === '') return
if (pin.length >= 6) return
onChange(p => p + d)
}
return (
<div className="grid grid-cols-3 gap-3">
{DIGITS.map((d, i) => (
<button
key={i}
type="button"
onClick={() => press(d)}
disabled={d === '' || disabled}
className={`h-14 rounded-xl text-xl 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>
)
}
// ─── Manager picker (multi-manager + login=none) ──────────────────────────────
function ManagerPicker({ managers, onSelect }) {
return (
<div className="space-y-3">
<div className="space-y-1">
<h1 className="text-xl font-bold text-slate-900">Select account</h1>
<p className="text-[13px] text-slate-500">Choose your manager account to continue.</p>
</div>
<div className="space-y-2">
{managers.map(m => (
<button
key={m.id}
onClick={() => onSelect(m)}
className="w-full flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 text-left transition hover:border-sky-300 hover:bg-sky-50 active:bg-sky-100"
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[13px] flex-shrink-0">
{(m.full_name || m.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-semibold text-slate-800">{m.full_name || m.username}</p>
<p className="text-[12px] text-slate-400">{m.username}</p>
</div>
</button>
))}
</div>
</div>
)
}
// ─── Main login page ──────────────────────────────────────────────────────────
export default function LoginPage() {
const [settings, setSettings] = useState(null)
const [managers, setManagers] = useState([])
const [loadingInit, setLoadingInit] = useState(true)
const [selectedManager, setSelectedManager] = useState(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
const autoLoginAttempted = useRef(false)
// manualLogout comes either from nav state (logout button) or sessionStorage (auto-logout)
const manualLogout = location.state?.manualLogout === true
|| sessionStorage.getItem('pos_manual_logout') === 'true'
// Write to sessionStorage when nav state says manualLogout, clear it on successful login
useEffect(() => {
if (location.state?.manualLogout) {
sessionStorage.setItem('pos_manual_logout', 'true')
}
}, [])
// Load security config + manager list in parallel (both public, no auth needed)
useEffect(() => {
Promise.all([
client.get('/api/setup/security-config').catch(() => ({ data: { login_method: 'password', autofill_username: true } })),
client.get('/api/auth/managers').catch(() => ({ data: [] })),
]).then(([configRes, managersRes]) => {
setSettings(configRes.data)
setManagers(managersRes.data)
}).finally(() => setLoadingInit(false))
}, [])
const loginMethod = settings?.login_method ?? 'password'
const autofill = settings?.autofill_username !== false
const singleManager = managers.length === 1 ? managers[0] : null
// Auto-login: none method + single manager → attempt exactly once, skip if manual logout
useEffect(() => {
if (!loadingInit && loginMethod === 'none' && singleManager && !manualLogout && !autoLoginAttempted.current) {
autoLoginAttempted.current = true
handleAutoLogin(singleManager.username)
}
}, [loadingInit, loginMethod, singleManager])
function clearManualLogoutFlag() {
sessionStorage.removeItem('pos_manual_logout')
}
async function handleAutoLogin(uname) {
setLoading(true)
try {
const { data } = await client.post('/api/auth/login-no-auth', { username: uname })
const role = data.user.role
if (role !== 'manager' && role !== 'sysadmin') { setLoading(false); return }
clearManualLogoutFlag()
login(data.user, data.access_token)
navigate('/operations', { replace: true })
} catch {
setLoading(false)
}
}
async function handleSubmit(e) {
e?.preventDefault()
const uname = selectedManager?.username || (autofill && singleManager ? singleManager.username : username.trim())
if (!uname) return
if (loginMethod === 'password' && !password) return
if (loginMethod === 'pin' && pin.length < 4) return
setError('')
setLoading(true)
try {
const body = { username: uname }
if (loginMethod === 'password') body.password = password
else if (loginMethod === 'pin') body.pin = pin
const { data } = await client.post('/api/auth/login', body)
const role = data.user.role
if (role !== 'manager' && role !== 'sysadmin') {
setError('This account does not have manager access.')
setPin('')
setPassword('')
return
}
clearManualLogoutFlag()
login(data.user, data.access_token)
navigate('/operations', { replace: true })
} catch (err) {
setError(err.response?.data?.detail || 'Invalid credentials')
setPin('')
setPassword('')
} finally {
setLoading(false)
}
}
// Auto-submit on 4-digit PIN entry
useEffect(() => {
if (loginMethod === 'pin' && pin.length === 4) handleSubmit()
}, [pin])
// Show spinner while loading OR while auto-login is in progress (none mode, not manual logout)
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout)) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
)
}
// Determine effective username to show
const effectiveUsername = selectedManager?.username
|| (autofill && singleManager ? singleManager.username : null)
// Multi-manager + none method: show picker first
const needsPicker = loginMethod === 'none' && managers.length > 1 && !selectedManager
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-2xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8">
{needsPicker ? (
<ManagerPicker managers={managers} onSelect={m => { setSelectedManager(m); handleAutoLogin(m.username) }} />
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center space-y-1">
<div className="flex justify-center mb-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-sky-500">
<KeyRound className="h-6 w-6 text-white" />
</div>
</div>
<h1 className="text-xl font-bold text-slate-900">Sign In</h1>
<p className="text-[13px] text-slate-500">
{loginMethod === 'password' ? 'Enter your username and password'
: loginMethod === 'pin' ? 'Enter your username and PIN'
: 'Select your account'}
</p>
</div>
{/* Username — show if not autofilled */}
{!effectiveUsername && (
<>
{managers.length > 1 ? (
<div className="space-y-2">
{managers.map(m => (
<button
key={m.id}
type="button"
onClick={() => setUsername(m.username)}
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
username === m.username
? 'border-sky-400 bg-sky-50'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{(m.full_name || m.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
<p className="text-[11px] text-slate-400">{m.username}</p>
</div>
</button>
))}
</div>
) : (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
)}
</>
)}
{/* Show who we're logging in as when autofilled */}
{effectiveUsername && (
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{effectiveUsername.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
<p className="text-[11px] text-slate-400">Manager</p>
</div>
</div>
)}
{/* Password input */}
{loginMethod === 'password' && (
<LabelledInput
icon={Lock}
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
type="password"
autoComplete="current-password"
autoFocus={!!effectiveUsername}
/>
)}
{/* PIN pad */}
{loginMethod === 'pin' && (
<div className="space-y-3">
<PinDots length={4} filled={pin.length} />
<PinPad pin={pin} onChange={setPin} disabled={loading} />
</div>
)}
{error && <p className="text-center text-[13px] text-rose-500">{error}</p>}
{/* None mode: just a Log In button */}
{loginMethod === 'none' && (
<Button
type="button"
variant="primary"
disabled={loading || (!effectiveUsername && !username.trim())}
className="w-full justify-center py-3"
onClick={() => handleAutoLogin(effectiveUsername || username.trim())}
>
{loading ? 'Signing in…' : 'Log In'}
</Button>
)}
{/* Password mode: submit button */}
{loginMethod === 'password' && (
<Button
type="submit"
variant="primary"
disabled={loading || (!effectiveUsername && !username.trim()) || !password}
className="w-full justify-center py-3"
>
{loading ? 'Signing in…' : 'Sign In'}
</Button>
)}
{/* PIN mode: auto-submits, show verifying text */}
{loading && loginMethod === 'pin' && (
<p className="text-center text-[13px] text-slate-400">Verifying</p>
)}
</form>
)}
</div>
<p className="text-center text-[11px] text-slate-400 mt-4">Xenia POS · Manager Dashboard</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,799 @@
import { useState, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../api/client'
// ── Small helpers ─────────────────────────────────────────────────────────────
function moveItem(arr, i, dir) {
const j = i + dir
if (j < 0 || j >= arr.length) return arr
const next = [...arr]
;[next[i], next[j]] = [next[j], next[i]]
return next
}
function HeartIcon({ filled, className = '' }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" className={`inline-block shrink-0 ${className}`}
fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8"
strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
)
}
function ReorderBtns({ onUp, onDown, disableUp, disableDown }) {
return (
<div className="flex flex-col shrink-0">
<button type="button" onClick={onUp} disabled={disableUp}
className="text-gray-400 hover:text-gray-600 disabled:opacity-20 leading-none px-1 py-0.5 text-xs"></button>
<button type="button" onClick={onDown} disabled={disableDown}
className="text-gray-400 hover:text-gray-600 disabled:opacity-20 leading-none px-1 py-0.5 text-xs"></button>
</div>
)
}
function DefaultBtn({ isDefault, onClick }) {
return (
<button type="button" onClick={onClick}
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-all ${
isDefault ? 'border-primary-600 bg-primary-600' : 'border-gray-300 bg-white hover:border-primary-400'
}`}>
{isDefault && <span className="w-2.5 h-2.5 rounded-full bg-white block" />}
</button>
)
}
function FavoriteBtn({ isFavorite, onClick }) {
return (
<button type="button" onClick={onClick}
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
isFavorite ? 'text-rose-500 hover:text-rose-400' : 'text-gray-300 hover:text-rose-400'
}`}>
<HeartIcon filled={isFavorite} className="w-4 h-4" />
</button>
)
}
function PriceInput({ value, onChange, placeholder, className = '', allowNegative = false }) {
const step = 0.10
const num = parseFloat(value) || 0
function inc() { onChange(Math.round((num + step) * 100) / 100) }
function dec() {
const next = Math.round((num - step) * 100) / 100
onChange(allowNegative ? next : Math.max(0, next))
}
return (
<div className={`flex items-center border border-gray-300 rounded-lg overflow-hidden h-10 ${className}`}>
<button type="button" onClick={dec}
className="px-2 h-full text-gray-500 hover:bg-gray-100 border-r border-gray-300 text-sm font-bold shrink-0"></button>
<input type="number" step="0.10" value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder ?? '0.00'}
className="flex-1 min-w-0 text-center text-sm outline-none bg-transparent px-1 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
<button type="button" onClick={inc}
className="px-2 h-full text-gray-500 hover:bg-gray-100 border-l border-gray-300 text-sm font-bold shrink-0">+</button>
</div>
)
}
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
if (!subChoices || subChoices.length === 0) return null
return (
<div className="border-t border-gray-100 bg-indigo-50/40 px-3 py-2 space-y-2">
<p className="text-xs text-indigo-500 font-medium mb-1">
Υπο-επιλογές του «{parentLabel || '…'}»
</p>
{subChoices.map((sc, sci) => (
<div key={sci} className="flex items-center gap-2 ml-4">
<ReorderBtns onUp={() => onMove(sci, -1)} onDown={() => onMove(sci, 1)}
disableUp={sci === 0} disableDown={sci === subChoices.length - 1} />
<DefaultBtn isDefault={sc.is_default} onClick={() => onToggleDefault(sci)} />
<input className="input flex-1 text-sm" placeholder="π.χ. Καραμέλα"
value={sc.name} onChange={e => onChange(sci, 'name', e.target.value)} />
<PriceInput value={sc.extra_cost} onChange={v => onChange(sci, 'extra_cost', v)}
allowNegative className="w-28 text-sm" />
<button onClick={() => onRemove(sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0"></button>
</div>
))}
<button onClick={onAdd} className="ml-4 btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">
+ Υπο-επιλογή
</button>
</div>
)
}
// ── Form builder ──────────────────────────────────────────────────────────────
function buildFormFromProduct(product) {
return {
name: product.name || '',
description: product.description || '',
category_id: product.category_id ?? '',
base_price: product.base_price ?? '',
is_available: product.is_available ?? true,
lifecycle_status: product.lifecycle_status ?? 'active',
printer_zone_id: product.printer_zone_id ?? '',
quick_options: product.quick_options?.map(q => ({
name: q.name, price: q.price ?? 0, allow_multiple: q.allow_multiple ?? false,
sort_order: q.sort_order ?? 0, is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0, is_compact: q.is_compact ?? false,
})) ?? [],
options: product.options?.map(o => ({
name: o.name, extra_cost: o.extra_cost ?? 0, allow_multiple: o.allow_multiple ?? false,
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
})) ?? [],
ingredients: product.ingredients?.map(i => ({
name: i.name, extra_cost: i.extra_cost ?? 0,
is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0,
})) ?? [],
preference_sets: product.preference_sets?.map(ps => ({
name: ps.name,
default_choice_index: ps.choices ? ps.choices.findIndex(c => c.id === ps.default_choice_id) : -1,
choices: ps.choices?.map(c => ({
name: c.name, extra_cost: c.extra_cost ?? 0, disables_subset: c.disables_subset ?? false,
sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
})) ?? [],
shared_subset: ps.shared_subset ? {
name: ps.shared_subset.name,
choices: ps.shared_subset.choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
} : null,
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
})) ?? [],
}
}
function buildFavoritesList(form) {
const items = []
form.quick_options.forEach((q, i) => { if (q.is_favorite) items.push({ type: 'quick', idx: i, favorite_sort_order: q.favorite_sort_order ?? 0 }) })
form.ingredients.forEach((ing, i) => { if (ing.is_favorite) items.push({ type: 'ingredient', idx: i, favorite_sort_order: ing.favorite_sort_order ?? 0 }) })
form.options.forEach((o, i) => { if (o.is_favorite) items.push({ type: 'option', idx: i, favorite_sort_order: o.favorite_sort_order ?? 0 }) })
form.preference_sets.forEach((ps, i) => { if (ps.is_favorite) items.push({ type: 'pref', idx: i, favorite_sort_order: ps.favorite_sort_order ?? 0 }) })
return items.sort((a, b) => a.favorite_sort_order - b.favorite_sort_order)
}
function setFavSortField(form, type, idx, value) {
if (type === 'quick') return { ...form, quick_options: form.quick_options.map((q, i) => i === idx ? { ...q, favorite_sort_order: value } : q) }
if (type === 'ingredient') return { ...form, ingredients: form.ingredients.map((ing, i) => i === idx ? { ...ing, favorite_sort_order: value } : ing) }
if (type === 'option') return { ...form, options: form.options.map((o, i) => i === idx ? { ...o, favorite_sort_order: value } : o) }
if (type === 'pref') return { ...form, preference_sets: form.preference_sets.map((ps, i) => i === idx ? { ...ps, favorite_sort_order: value } : ps) }
return form
}
function getItemLabel(form, type, idx) {
if (type === 'quick') return form.quick_options[idx]?.name || '(χωρίς όνομα)'
if (type === 'ingredient') return form.ingredients[idx]?.name || '(χωρίς όνομα)'
if (type === 'option') return form.options[idx]?.name || '(χωρίς όνομα)'
if (type === 'pref') return form.preference_sets[idx]?.name || '(χωρίς όνομα)'
return ''
}
function getItemTypeLabel(type) {
if (type === 'quick') return 'Γρήγορη'
if (type === 'ingredient') return 'Υλικό'
if (type === 'option') return 'Έξτρα'
if (type === 'pref') return 'Προτίμηση'
return ''
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
const [form, setForm] = useState(() => buildFormFromProduct(product))
const [activeTab, setActiveTab] = useState('favorites')
const [imageFile, setImageFile] = useState(null)
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
useEffect(() => {
setForm(buildFormFromProduct(product))
setActiveTab('favorites')
setImageFile(null)
}, [product.id, product.name])
function setField(k, v) { setForm(f => ({ ...f, [k]: v })) }
// Favorites reorder
function moveFavorite(favList, favIdx, dir) {
const newList = [...favList]
const swapIdx = favIdx + dir
if (swapIdx < 0 || swapIdx >= newList.length) return
const aOrder = newList[favIdx].favorite_sort_order
const bOrder = newList[swapIdx].favorite_sort_order
setForm(f => {
let next = setFavSortField(f, newList[favIdx].type, newList[favIdx].idx, bOrder)
next = setFavSortField(next, newList[swapIdx].type, newList[swapIdx].idx, aOrder)
return next
})
}
function toggleFavorite(type, idx) {
setForm(f => {
const currentFavs = buildFavoritesList(f)
const isFav = (() => {
if (type === 'quick') return f.quick_options[idx]?.is_favorite
if (type === 'ingredient') return f.ingredients[idx]?.is_favorite
if (type === 'option') return f.options[idx]?.is_favorite
if (type === 'pref') return f.preference_sets[idx]?.is_favorite
})()
const newSortOrder = isFav ? 0 : (currentFavs.length > 0 ? Math.max(...currentFavs.map(x => x.favorite_sort_order)) + 1 : 0)
if (type === 'quick') return { ...f, quick_options: f.quick_options.map((q, i) => i === idx ? { ...q, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : q) }
if (type === 'ingredient') return { ...f, ingredients: f.ingredients.map((ing, i) => i === idx ? { ...ing, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ing) }
if (type === 'option') return { ...f, options: f.options.map((o, i) => i === idx ? { ...o, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : o) }
if (type === 'pref') return { ...f, preference_sets: f.preference_sets.map((ps, i) => i === idx ? { ...ps, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ps) }
return f
})
}
// Quick Options
function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
// Options
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) }
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) }
function moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) }
function addOptionSubChoice(oi) {
setForm(f => ({ ...f, options: f.options.map((o, idx) =>
idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] }
)}))
}
function removeOptionSubChoice(oi, sci) {
setForm(f => ({ ...f, options: f.options.map((o, idx) =>
idx !== oi ? o : { ...o, sub_choices: o.sub_choices.filter((_, i) => i !== sci) }
)}))
}
function setOptionSubChoice(oi, sci, k, v) {
setForm(f => ({ ...f, options: f.options.map((o, idx) =>
idx !== oi ? o : {
...o, sub_choices: o.sub_choices.map((sc, scidx) => {
if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc
return { ...sc, [k]: v }
})
}
)}))
}
function moveOptionSubChoice(oi, sci, dir) {
setForm(f => ({ ...f, options: f.options.map((o, idx) =>
idx !== oi ? o : { ...o, sub_choices: moveItem(o.sub_choices, sci, dir) }
)}))
}
function toggleOptionSubDefault(oi, sci) {
const sc = form.options[oi]?.sub_choices?.[sci]
setOptionSubChoice(oi, sci, 'is_default', !sc?.is_default)
}
// Ingredients
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0 }] })) }
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) }
// Preference sets
function addPrefSet() {
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] }))
setActiveTab(form.preference_sets.length)
}
function removePrefSet(si) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) }))
setActiveTab('favorites')
}
function setPrefSetField(si, k, v) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, [k]: v } : ps) }))
}
function addChoice(si) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) =>
idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, sub_choices: [] }] } : ps
)}))
}
function removeChoice(si, ci) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
const newChoices = ps.choices.filter((_, cidx) => cidx !== ci)
const d = ps.default_choice_index
const newDefault = d === ci ? -1 : d > ci ? d - 1 : d
return { ...ps, choices: newChoices, default_choice_index: newDefault }
})}))
}
function setChoice(si, ci, k, v) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) =>
idx === si ? { ...ps, choices: ps.choices.map((ch, cidx) => cidx === ci ? { ...ch, [k]: v } : ch) } : ps
)}))
}
function moveChoice(si, ci, dir) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
const newChoices = moveItem(ps.choices, ci, dir)
const j = ci + dir
let nd = ps.default_choice_index
if (nd === ci) nd = j; else if (nd === j) nd = ci
return { ...ps, choices: newChoices, default_choice_index: nd }
})}))
}
function toggleDefaultChoice(si, ci) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) =>
idx !== si ? ps : { ...ps, default_choice_index: ps.default_choice_index === ci ? -1 : ci }
)}))
}
function addSubChoice(si, ci) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) =>
pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) =>
cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] }
)}
)}))
}
function removeSubChoice(si, ci, sci) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) =>
pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) =>
cidx !== ci ? ch : { ...ch, sub_choices: ch.sub_choices.filter((_, i) => i !== sci) }
)}
)}))
}
function setSubChoice(si, ci, sci, k, v) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) =>
pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) =>
cidx !== ci ? ch : { ...ch, sub_choices: ch.sub_choices.map((sc, scidx) => {
if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc
return { ...sc, [k]: v }
})}
)}
)}))
}
function moveSubChoice(si, ci, sci, dir) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) =>
pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) =>
cidx !== ci ? ch : { ...ch, sub_choices: moveItem(ch.sub_choices, sci, dir) }
)}
)}))
}
function setSharedSubsetName(si, name) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
return { ...ps, shared_subset: ps.shared_subset ? { ...ps.shared_subset, name } : { name, choices: [] } }
})}))
}
function addSharedSubsetChoice(si) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
const ss = ps.shared_subset || { name: '', choices: [] }
return { ...ps, shared_subset: { ...ss, choices: [...ss.choices, { name: '', extra_cost: 0, is_default: false }] } }
})}))
}
function removeSharedSubsetChoice(si, sci) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
const newChoices = ps.shared_subset.choices.filter((_, i) => i !== sci)
return { ...ps, shared_subset: newChoices.length ? { ...ps.shared_subset, choices: newChoices } : null }
})}))
}
function setSharedSubsetChoice(si, sci, k, v) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
const newChoices = ps.shared_subset.choices.map((sc, scidx) => {
if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc
return { ...sc, [k]: v }
})
return { ...ps, shared_subset: { ...ps.shared_subset, choices: newChoices } }
})}))
}
function moveSharedSubsetChoice(si, sci, dir) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) =>
idx !== si ? ps : { ...ps, shared_subset: { ...ps.shared_subset, choices: moveItem(ps.shared_subset.choices, sci, dir) } }
)}))
}
function buildBody() {
return {
name: form.name,
description: form.description || null,
category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price),
is_available: form.is_available,
lifecycle_status: form.lifecycle_status,
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
quick_options: form.quick_options.map((q, i) => ({
name: q.name, price: parseFloat(q.price) || 0, allow_multiple: q.allow_multiple ?? false,
sort_order: i, is_favorite: q.is_favorite ?? false, favorite_sort_order: q.favorite_sort_order ?? 0, is_compact: q.is_compact ?? false,
})),
options: form.options.map(o => ({
name: o.name, extra_cost: parseFloat(o.extra_cost) || 0, allow_multiple: o.allow_multiple ?? false,
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
})),
ingredients: form.ingredients.map(i => ({
name: i.name, extra_cost: parseFloat(i.extra_cost) || 0,
is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0,
})),
preference_sets: form.preference_sets.map(ps => ({
name: ps.name,
default_choice_index: ps.default_choice_index >= 0 ? ps.default_choice_index : null,
shared_subset: ps.shared_subset?.choices?.length ? {
name: ps.shared_subset.name || '',
choices: ps.shared_subset.choices.map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
} : null,
choices: ps.choices.map(c => ({
name: c.name, extra_cost: parseFloat(c.extra_cost) || 0, disables_subset: c.disables_subset ?? false,
sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
})),
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
})),
}
}
async function submit() {
if (!imageFile) { onSave(buildBody()); return }
if (!isNew) {
onSave(buildBody())
setUploading(true)
try {
const fd = new FormData(); fd.append('file', imageFile)
await client.post(`/api/products/${product.id}/image`, fd)
qc.invalidateQueries({ queryKey: ['products-all'] })
} catch { toast.error('Σφάλμα ανεβάσματος εικόνας') }
finally { setUploading(false) }
} else {
setUploading(true)
try {
const res = await client.post('/api/products/', buildBody())
const newId = res.data.id
const fd = new FormData(); fd.append('file', imageFile)
await client.post(`/api/products/${newId}/image`, fd)
qc.invalidateQueries({ queryKey: ['products-all'] })
onClose()
} catch { toast.error('Σφάλμα αποθήκευσης') }
finally { setUploading(false) }
}
}
const isNew = !product.id
const canSave = form.name.trim() && form.base_price
const favCount = buildFavoritesList(form).length
const tabs = [
{ key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true },
{ key: 'quick', label: 'Γρήγορες', count: form.quick_options.length },
{ key: 'ingredients', label: 'Υλικά', count: form.ingredients.length },
{ key: 'options', label: 'Έξτρα', count: form.options.length },
...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })),
{ key: '__add_pref__', label: ' Προτίμηση', isAdd: true },
]
const favList = buildFavoritesList(form)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden" style={{ width: '90vw', height: '92vh' }}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<h2 className="font-bold text-gray-800 text-lg">
{isNew ? 'Νέο προϊόν' : `Επεξεργασία — ${product.name}`}
</h2>
<button onClick={onClose} className="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center text-gray-500 text-lg"></button>
</div>
{/* Body */}
<div className="flex-1 flex overflow-hidden">
{/* LEFT: product info */}
<div className="w-80 shrink-0 border-r border-gray-100 bg-gray-50/50 px-5 py-5 flex flex-col gap-3 overflow-y-auto">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Στοιχεία προϊόντος</p>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
</div>
{/* Description — optional, for digital menus / staff info */}
<div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
rows={3}
value={form.description}
onChange={e => setField('description', e.target.value)}
placeholder="π.χ. Φρέσκος καφές με γάλα βρώμης, εξαιρετικά αρώματα…"
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
<div>
<label className="label">Τιμή βάσης () *</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
<option value=""> Χωρίς κατηγορία </option>
{categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
return [
<option key={parent.id} value={parent.id}>{parent.name}</option>,
...subs.map(s => <option key={s.id} value={s.id}>&nbsp;&nbsp; {s.name}</option>),
]
})}
</select>
</div>
<div>
<label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
<option value=""> Χωρίς εκτυπωτή </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<button type="button" onClick={() => setField('is_available', !form.is_available)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
}`}>
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
</button>
{/* Image upload */}
<div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
)}
{imageFile && <div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>}
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</div>
{/* RIGHT: tabs */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
{tabs.map(tab => {
if (tab.isAdd) return (
<button key="__add_pref__" onClick={addPrefSet}
className="px-4 py-3 text-sm font-medium text-primary-600 hover:bg-primary-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
{tab.label}
</button>
)
const isActive = activeTab === tab.key
return (
<button key={String(tab.key)} onClick={() => setActiveTab(tab.key)}
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
isActive ? 'border-primary-600 text-primary-700 bg-primary-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}>
{tab.isFavTab && <HeartIcon filled={favCount > 0} className={`w-3.5 h-3.5 ${favCount > 0 ? 'text-rose-500' : 'text-gray-400'}`} />}
{tab.label}
{tab.count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'}`}>
{tab.count}
</span>
)}
</button>
)
})}
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Favorites */}
{activeTab === 'favorites' && (
<div>
<p className="text-sm text-gray-500 mb-4">Αγαπημένα εμφανίζονται πρώτα στον σερβιτόρο.</p>
{favList.length === 0 && <p className="text-sm text-gray-400 text-center py-12">Δεν υπάρχουν αγαπημένα.</p>}
<div className="space-y-2">
{favList.map((fav, fi) => (
<div key={`${fav.type}-${fav.idx}`} className="flex items-center gap-3 border border-rose-100 bg-rose-50/30 rounded-xl p-3">
<ReorderBtns onUp={() => moveFavorite(favList, fi, -1)} onDown={() => moveFavorite(favList, fi, 1)}
disableUp={fi === 0} disableDown={fi === favList.length - 1} />
<HeartIcon filled className="w-4 h-4 text-rose-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{getItemLabel(form, fav.type, fav.idx)}</p>
<p className="text-xs text-gray-400">{getItemTypeLabel(fav.type)}</p>
</div>
<button type="button" onClick={() => toggleFavorite(fav.type, fav.idx)}
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 rounded hover:bg-red-50">Αφαίρεση</button>
</div>
))}
</div>
</div>
)}
{/* Quick Options */}
{activeTab === 'quick' && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-500">Γρήγορες επιλογές.</p>
<button onClick={addQuickOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Επιλογή</button>
</div>
{!form.quick_options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν γρήγορες επιλογές.</p>}
<div className="space-y-2">
{form.quick_options.map((q, i) => (
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${q.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<ReorderBtns onUp={() => moveQuickOption(i, -1)} onDown={() => moveQuickOption(i, 1)}
disableUp={i === 0} disableDown={i === form.quick_options.length - 1} />
<FavoriteBtn isFavorite={q.is_favorite} onClick={() => toggleFavorite('quick', i)} />
<input className="input flex-1" placeholder="π.χ. Extra Bacon" value={q.name} onChange={e => setQuickOption(i, 'name', e.target.value)} />
<PriceInput value={q.price} onChange={v => setQuickOption(i, 'price', v)} className="w-32" />
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
<input type="checkbox" checked={q.allow_multiple} onChange={e => setQuickOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
Πολλαπλά
</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: q.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={q.is_compact ?? false} onChange={e => setQuickOption(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
<button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div>
))}
</div>
</div>
)}
{/* Ingredients */}
{activeTab === 'ingredients' && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
</div>
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
<div className="space-y-2">
{form.ingredients.map((ing, i) => (
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
<input className="input flex-1" placeholder="Όνομα υλικού" value={ing.name} onChange={e => setIngredient(i, 'name', e.target.value)} />
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)} allowNegative className="w-32" />
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div>
))}
</div>
</div>
)}
{/* Options/Extras */}
{activeTab === 'options' && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
</div>
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
<div className="space-y-3">
{form.options.map((opt, i) => (
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
disableUp={i === 0} disableDown={i === form.options.length - 1} />
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
<input className="input flex-1 min-w-40" placeholder="π.χ. Κανέλα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)} allowNegative className="w-32" />
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
<input type="checkbox" checked={opt.allow_multiple} onChange={e => setOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
Πολλαπλά
</label>
<button onClick={() => addOptionSubChoice(i)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div>
<SubChoiceRows subChoices={opt.sub_choices} parentLabel={opt.name}
onMove={(sci, dir) => moveOptionSubChoice(i, sci, dir)}
onToggleDefault={sci => toggleOptionSubDefault(i, sci)}
onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)}
onRemove={sci => removeOptionSubChoice(i, sci)}
onAdd={() => addOptionSubChoice(i)} />
</div>
))}
</div>
</div>
)}
{/* Preference set tab */}
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
const si = activeTab
const ps = form.preference_sets[si]
const hasSharedSubset = !!ps.shared_subset
return (
<div>
<div className="flex items-center gap-3 mb-4">
<input className="input flex-1 font-semibold text-base" placeholder="π.χ. Ζάχαρη" value={ps.name}
onChange={e => setPrefSetField(si, 'name', e.target.value)} autoFocus />
<FavoriteBtn isFavorite={ps.is_favorite} onClick={() => toggleFavorite('pref', si)} />
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-3 min-h-0 h-10 shrink-0">Διαγραφή</button>
</div>
<p className="text-xs text-gray-400 mb-3"> = προεπιλογή · = απενεργοποιεί κοινό υπο-σύνολο</p>
<div className="space-y-3 mb-5">
{ps.choices.map((ch, ci) => (
<div key={ci} className="border border-gray-200 rounded-xl overflow-hidden">
<div className="flex items-center gap-2 p-3 bg-white">
<ReorderBtns onUp={() => moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)}
disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} />
<DefaultBtn isDefault={ps.default_choice_index === ci} onClick={() => toggleDefaultChoice(si, ci)} />
<input className="input flex-1" placeholder="π.χ. Σκέτος" value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
<PriceInput value={ch.extra_cost} onChange={v => setChoice(si, ci, 'extra_cost', v)} allowNegative className="w-32" />
{hasSharedSubset && (
<button type="button" onClick={() => setChoice(si, ci, 'disables_subset', !ch.disables_subset)}
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-sm ${ch.disables_subset ? 'bg-red-100 text-red-500' : 'text-gray-300 hover:text-red-400'}`}></button>
)}
<button onClick={() => addSubChoice(si, ci)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
<button onClick={() => removeChoice(si, ci)} className="btn btn-danger px-2 min-h-0 h-9 shrink-0"></button>
</div>
<SubChoiceRows subChoices={ch.sub_choices} parentLabel={ch.name}
onMove={(sci, dir) => moveSubChoice(si, ci, sci, dir)}
onToggleDefault={sci => setSubChoice(si, ci, sci, 'is_default', !ch.sub_choices[sci]?.is_default)}
onChange={(sci, k, v) => setSubChoice(si, ci, sci, k, v)}
onRemove={sci => removeSubChoice(si, ci, sci)}
onAdd={() => addSubChoice(si, ci)} />
</div>
))}
</div>
<button onClick={() => addChoice(si)} className="btn btn-secondary text-sm px-4 py-1.5 min-h-0 h-9">+ Επιλογή</button>
<div className="mt-6 border-t border-gray-100 pt-5">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-semibold text-gray-700">Κοινό υπο-σύνολο</p>
<p className="text-xs text-gray-400">Εμφανίζεται για όλες εκτός αυτών με </p>
</div>
{!ps.shared_subset
? <button onClick={() => setSharedSubsetName(si, '')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Κοινό υπο-σύνολο</button>
: <button onClick={() => setPrefSetField(si, 'shared_subset', null)} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Αφαίρεση</button>
}
</div>
{ps.shared_subset && (
<div className="border border-indigo-200 rounded-xl p-4 bg-indigo-50/30 space-y-3">
<div>
<label className="label text-xs">Όνομα</label>
<input className="input text-sm" placeholder="π.χ. Είδος ζάχαρης" value={ps.shared_subset.name || ''} onChange={e => setSharedSubsetName(si, e.target.value)} />
</div>
<div className="space-y-2">
{(ps.shared_subset.choices || []).map((sc, sci) => (
<div key={sci} className="flex items-center gap-2">
<ReorderBtns onUp={() => moveSharedSubsetChoice(si, sci, -1)} onDown={() => moveSharedSubsetChoice(si, sci, 1)}
disableUp={sci === 0} disableDown={sci === ps.shared_subset.choices.length - 1} />
<DefaultBtn isDefault={sc.is_default} onClick={() => setSharedSubsetChoice(si, sci, 'is_default', !sc.is_default)} />
<input className="input flex-1 text-sm" placeholder="π.χ. Λευκή" value={sc.name} onChange={e => setSharedSubsetChoice(si, sci, 'name', e.target.value)} />
<PriceInput value={sc.extra_cost} onChange={v => setSharedSubsetChoice(si, sci, 'extra_cost', v)} allowNegative className="w-32 text-sm" />
<button onClick={() => removeSharedSubsetChoice(si, sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0"></button>
</div>
))}
</div>
<button onClick={() => addSharedSubsetChoice(si)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Επιλογή</button>
</div>
)}
</div>
</div>
)
})()}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-100 bg-gray-50/50 shrink-0 gap-3">
<button onClick={onClose} className="btn btn-secondary px-6">Ακύρωση</button>
<div className="flex gap-3">
{!isNew && (
<button onClick={() => onCopy({ ...form })} className="btn btn-secondary px-6 text-indigo-600 border-indigo-200 hover:bg-indigo-50">
📋 Αντιγραφή
</button>
)}
<button onClick={submit} disabled={!canSave || uploading} className="btn btn-primary px-8">
{uploading ? 'Ανέβασμα…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import { useState } from 'react'
import { ShoppingBag, LayoutGrid, Users } from 'lucide-react'
import ProductsTab from './Management/ProductsTab'
import TablesConfigTab from './TablesConfigTab'
import StaffTab from './StaffTab'
import { TabGroup } from '../ui/Tabs'
const TABS = [
{ id: 'products', label: 'Προϊόντα', icon: ShoppingBag },
{ id: 'tables', label: 'Τραπέζια', icon: LayoutGrid },
{ id: 'staff', label: 'Προσωπικό', icon: Users },
]
export default function ManagementPage() {
const [activeTab, setActiveTab] = useState('products')
return (
<div className="flex flex-col h-full min-h-0">
<TabGroup tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="flex-1 min-h-0">
{activeTab === 'products' && <ProductsTab />}
{activeTab === 'tables' && <TablesConfigTab />}
{activeTab === 'staff' && <StaffTab />}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal'
function PrintOrderModal({ onClose, onPrint, printers }) {
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
function submit() {
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
onPrint(Number(printerId))
onClose()
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Εκτύπωση παραγγελίας</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div>
<label className="label">Εκτυπωτής</label>
<select className="input w-full" value={printerId} onChange={e => setPrinterId(e.target.value)}>
<option value=""> Επιλέξτε </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="btn btn-secondary flex-1">Ακύρωση</button>
<button onClick={submit} className="btn btn-primary flex-1">Εκτύπωση</button>
</div>
</div>
</div>
)
}
function itemTotal(item) {
return (item.unit_price * item.quantity).toFixed(2)
}
function formatDate(dt) {
if (!dt) return '—'
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
}
const EVENT_LABELS = {
ORDER_OPENED: 'Άνοιγμα',
ITEMS_ADDED: 'Προσθήκη',
PAYMENT: 'Πληρωμή',
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
ORDER_CLOSED: 'Κλείσιμο',
ORDER_CANCELLED: 'Ακύρωση',
ITEM_CANCELLED: 'Ακύρωση αντ.',
}
function AuditTab({ order, waiterMap }) {
if (!order.audit_logs || order.audit_logs.length === 0) {
return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
}
return (
<div className="divide-y divide-gray-100">
{order.audit_logs.map(log => {
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
const badgeClass = isDuplicate
? 'bg-red-100 text-red-700'
: isPayment ? 'bg-green-100 text-green-700'
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
: 'bg-blue-100 text-blue-700'
// Show offline_at (real payment time) when available, else server created_at
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
return (
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
<div className="shrink-0 mt-0.5">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${badgeClass}`}>
{EVENT_LABELS[log.event_type] ?? log.event_type}
</span>
{isDuplicate && (
<span className="block text-xs text-red-500 font-semibold mt-0.5">ΔΙΠΛΗ</span>
)}
</div>
<div className="flex-1 min-w-0 text-sm text-gray-700">
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
{log.amount != null && (
<span className={`ml-2 font-semibold ${isDuplicate ? 'text-red-600' : 'text-green-700'}`}>
{log.amount.toFixed(2)}
</span>
)}
{log.payment_method && (
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
)}
</div>
<div className="text-right shrink-0">
<span className="text-xs text-gray-400">{displayTime}</span>
{log.offline_at && (
<span className="block text-xs text-orange-400">offline</span>
)}
</div>
</div>
)
})}
</div>
)
}
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
const { orderId: paramOrderId } = useParams()
const orderId = propOrderId ?? paramOrderId
const navigate = useNavigate()
const qc = useQueryClient()
const [tab, setTab] = useState('overview')
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
const [showPrintModal, setShowPrintModal] = useState(false)
const { data: order, isLoading } = useQuery({
queryKey: ['order', orderId],
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
enabled: !!orderId,
})
const { data: waiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 60_000,
})
const printOrder = useMutation({
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['order', orderId] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
}
const cancelItem = useMutation({
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα ακύρωσης αντικειμένου'),
})
const cancelOrder = useMutation({
mutationFn: () => client.delete(`/api/orders/${orderId}`),
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/tables') },
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
})
const closeOrder = useMutation({
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/tables') },
onError: () => toast.error('Σφάλμα κλεισίματος'),
})
const assignWaiter = useMutation({
mutationFn: (waiter_id) => client.put(`/api/orders/${orderId}/assign-waiter`, { waiter_id }),
onSuccess: () => { toast.success('Σερβιτόρος προστέθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const removeWaiter = useMutation({
mutationFn: (wid) => client.delete(`/api/orders/${orderId}/waiters/${wid}`),
onSuccess: () => { toast.success('Σερβιτόρος αφαιρέθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const payItems = useMutation({
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα πληρωμής'),
})
function handleConfirm() {
if (!confirmAction) return
const { type, payload } = confirmAction
if (type === 'cancelItem') cancelItem.mutate(payload)
if (type === 'cancelOrder') cancelOrder.mutate()
if (type === 'closeOrder') closeOrder.mutate()
setConfirmAction(null)
}
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
if (!order) return <div className="text-center py-16 text-gray-400">Παραγγελία δεν βρέθηκε.</div>
const activeItems = order.items.filter(i => i.status === 'active')
const total = order.items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const isOpen = ['open', 'partially_paid'].includes(order.status)
return (
<div className="overflow-y-auto h-full p-6">
<div className="max-w-3xl mx-auto space-y-6">
{!propOrderId && (
<button onClick={() => navigate(-1)} className="btn btn-ghost text-sm"> Πίσω</button>
)}
{/* Header */}
<div className="card p-5 flex flex-wrap gap-4 items-start justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
<p className="text-sm text-gray-500 mt-1">
Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
</p>
</div>
<div className="flex items-center gap-3">
<Badge status={order.status} />
<span className="text-lg font-bold text-gray-800">{total.toFixed(2)}</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
{label}
</button>
))}
</div>
{tab === 'overview' && <>
{/* Waiters */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Προσωπικό</h2>
<div className="flex flex-wrap gap-2">
{order.waiters.map(w => (
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
{isOpen && !readOnly && (
<button
onClick={() => removeWaiter.mutate(w.waiter_id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none"
>
</button>
)}
</div>
))}
{isOpen && !readOnly && (
<select
className="text-sm border border-gray-300 rounded-full pl-3 pr-8 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
defaultValue=""
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
>
<option value="">+ Πρόσθεσε</option>
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
<option key={w.id} value={w.id}>{w.nickname || w.full_name || w.username}</option>
))}
</select>
)}
</div>
</div>
{/* Items */}
<div className="card divide-y divide-gray-100">
<div className="px-4 py-3">
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
</div>
{order.items.length === 0 && (
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
)}
{order.items.map(item => (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
<p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p>
{item.paid_by && (
<p className="text-xs text-green-600 mt-0.5">
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge status={item.status} />
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div>
))}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
{isOpen && !readOnly && activeItems.length > 0 && (
<button
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
className="btn btn-primary"
>
Πληρωμή όλων
</button>
)}
{isOpen && !readOnly && (
<>
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
className="btn btn-secondary"
>
Κλείσιμο παραγγελίας
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
className="btn btn-danger"
>
Ακύρωση παραγγελίας
</button>
</>
)}
<button
onClick={() => setShowPrintModal(true)}
className="btn btn-secondary"
>
🖨 Εκτύπωση
</button>
</div>
</>}
{tab === 'audit' && (
<div className="card divide-y divide-gray-100">
<AuditTab order={order} waiterMap={waiterMap} />
</div>
)}
{confirmAction && (
<ConfirmModal
title={
confirmAction.type === 'cancelItem' ? 'Ακύρωση αντικειμένου;' :
confirmAction.type === 'cancelOrder' ? 'Ακύρωση παραγγελίας;' :
'Κλείσιμο παραγγελίας;'
}
message={
confirmAction.type === 'cancelOrder'
? 'Η παραγγελία θα ακυρωθεί οριστικά.'
: undefined
}
confirmLabel={confirmAction.type === 'closeOrder' ? 'Κλείσιμο' : 'Ακύρωση'}
confirmVariant={confirmAction.type === 'closeOrder' ? 'primary' : 'danger'}
onConfirm={handleConfirm}
onCancel={() => setConfirmAction(null)}
/>
)}
{showPrintModal && printers.length > 0 && (
<PrintOrderModal
printers={printers}
onClose={() => setShowPrintModal(false)}
onPrint={(printerId) => printOrder.mutate(printerId)}
/>
)}
{showPrintModal && printers.length === 0 && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
<button onClick={() => setShowPrintModal(false)} className="btn btn-secondary w-full">Κλείσιμο</button>
</div>
</div>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import { useState } from 'react'
import AppInfoTab from './tabs/AppInfoTab'
import ColoursTab from './tabs/ColoursTab'
import DevelopmentTab from './tabs/DevelopmentTab'
import OperationTab from './tabs/OperationTab'
import PrintFontsTab from './tabs/PrintFontsTab'
import SecurityTab from './tabs/SecurityTab'
import { TabGroup } from '../../ui/Tabs'
const TABS = [
{ id: 'app-info', label: 'Γενικά' },
{ id: 'security', label: 'Ασφάλεια' },
{ id: 'operation', label: 'Λειτουργία' },
{ id: 'colours', label: 'Εμφάνιση' },
{ id: 'print-fonts', label: 'Εκτύπωση' },
{ id: 'development', label: 'dev' },
]
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState('app-info')
return (
<div className="flex flex-col h-full min-h-0">
<TabGroup tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="flex-1 overflow-y-auto p-6">
{activeTab === 'app-info' && <AppInfoTab />}
{activeTab === 'security' && <SecurityTab />}
{activeTab === 'operation' && <OperationTab />}
{activeTab === 'colours' && <ColoursTab />}
{activeTab === 'print-fonts' && <PrintFontsTab />}
{activeTab === 'development' && <DevelopmentTab />}
</div>
</div>
)
}

View File

@@ -0,0 +1,458 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { QRCodeSVG } from 'qrcode.react'
import client from '../../../api/client'
import useAuthStore from '../../../store/authStore'
const COMMON_TIMEZONES = [
'Europe/Athens', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Rome',
'Europe/Madrid', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Bucharest',
'Europe/Helsinki', 'Europe/Istanbul', 'America/New_York', 'America/Chicago',
'America/Denver', 'America/Los_Angeles', 'UTC',
]
function TimezoneSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const currentTz = settings?.['system.timezone']?.value ?? 'Europe/Athens'
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone
return (
<div className="card divide-y divide-gray-100">
<div className="px-5 py-4">
<h2 className="font-semibold text-gray-700">Ζώνη Ώρας</h2>
<p className="text-xs text-gray-400 mt-0.5">
Η ζώνη ώρας που χρησιμοποιεί το backend για χρονοσφραγίδες. Αν οι ώρες έναρξης βάρδιας εμφανίζονται λανθασμένες, ρυθμίστε αυτό να ταιριάζει με την τοπική σας ζώνη.
</p>
</div>
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<div className="px-5 py-4 space-y-3">
<div className="flex items-center gap-3">
<select
value={currentTz}
onChange={e => updateMut.mutate({ key: 'system.timezone', value: e.target.value })}
disabled={updateMut.isPending}
className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none flex-1 max-w-xs"
>
{COMMON_TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
{updateMut.isPending && <span className="text-xs text-gray-400">Αποθήκευση</span>}
</div>
<p className="text-xs text-gray-400">
Ζώνη ώρας browser: <span className="font-medium text-gray-600">{browserTz}</span>
{browserTz !== currentTz && (
<span className="ml-2 text-amber-600 font-medium"> Διαφέρει από τη ρύθμιση backend</span>
)}
</p>
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Η αλλαγή ζώνης ώρας αποθηκεύεται και εφαρμόζεται στο frontend αμέσως. Για πλήρη εφαρμογή στον backend server (χρονοσφραγίδες), απαιτείται επανεκκίνηση του container.
</p>
</div>
)}
</div>
)
}
function StatsSection() {
const { data: stats, isLoading } = useQuery({
queryKey: ['system-stats'],
queryFn: () => client.get('/api/system/stats').then(r => r.data),
staleTime: 60_000,
})
const rows = [
{ label: 'Κατηγορίες', value: stats?.categories },
{ label: 'Προϊόντα (ενεργά)', value: stats?.products },
{ label: 'Τραπέζια (ενεργά)', value: stats?.tables },
{ label: 'Ζώνες Τραπεζιών', value: stats?.table_groups },
{ label: 'Managers', value: stats?.managers },
{ label: 'Σερβιτόροι', value: stats?.waiters },
]
return (
<div className="card p-5 space-y-3">
<h2 className="font-semibold text-gray-700">Στατιστικά Συστήματος</h2>
{isLoading && <p className="text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<div className="grid grid-cols-2 gap-3 text-sm">
{rows.map(({ label, value }) => (
<>
<div key={label + '-label'} className="text-gray-500">{label}</div>
<div key={label + '-value'} className="font-medium text-gray-800">{value ?? '—'}</div>
</>
))}
</div>
)}
</div>
)
}
function DoubleConfirmImport({ isOpen, onClose, onConfirm, title, summary, isPending }) {
const [step, setStep] = useState(1)
function handleClose() {
setStep(1)
onClose()
}
function handleFirst() {
setStep(2)
}
async function handleFinal() {
await onConfirm()
setStep(1)
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
{step === 1 && (
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<h3 className="font-semibold text-gray-800 text-lg">Επιβεβαίωση Εισαγωγής</h3>
<p className="text-sm text-gray-600">{summary}</p>
<p className="text-xs text-gray-400">Τα υπάρχοντα δεδομένα θα συγχωνευτούν δεν θα διαγραφεί τίποτα.</p>
<div className="flex gap-3 justify-end">
<button onClick={handleClose} className="btn">Άκυρο</button>
<button onClick={handleFirst} className="btn btn-primary">Συνέχεια </button>
</div>
</div>
)}
{step === 2 && (
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4 border-2 border-red-400">
<h3 className="font-bold text-red-700 text-lg"> ΤΕΛΕΥΤΑΙΑ ΠΡΟΕΙΔΟΠΟΙΗΣΗ</h3>
<p className="text-sm text-gray-700 font-medium">{title}</p>
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
Αυτή η ενέργεια <strong>δεν αναιρείται</strong>. Η βάση δεδομένων θα τροποποιηθεί μόνιμα.
Είστε απολύτως σίγουροι;
</p>
<div className="flex gap-3 justify-end">
<button onClick={handleClose} className="btn">Άκυρο</button>
<button
onClick={handleFinal}
disabled={isPending}
className="btn btn-danger"
>
{isPending ? 'Εισαγωγή…' : 'ΝΑΙ, ΕΙΣΑΓΩΓΗ'}
</button>
</div>
</div>
)}
</div>
)
}
function DataTransferSection() {
const qc = useQueryClient()
const [catalogModal, setCatalogModal] = useState(false)
const [tablesModal, setTablesModal] = useState(false)
const [catalogPayload, setCatalogPayload] = useState(null)
const [tablesPayload, setTablesPayload] = useState(null)
const [catalogSummary, setCatalogSummary] = useState('')
const [tablesSummary, setTablesSummary] = useState('')
const catalogImportMut = useMutation({
mutationFn: (payload) => client.post('/api/data-transfer/import/catalog', payload).then(r => r.data),
onSuccess: () => {
toast.success('Κατάλογος εισήχθη επιτυχώς')
setCatalogModal(false)
setCatalogPayload(null)
qc.invalidateQueries({ queryKey: ['system-stats'] })
qc.invalidateQueries({ queryKey: ['products'] })
qc.invalidateQueries({ queryKey: ['categories'] })
},
onError: (err) => toast.error(err?.response?.data?.detail ?? 'Σφάλμα εισαγωγής'),
})
const tablesImportMut = useMutation({
mutationFn: (payload) => client.post('/api/data-transfer/import/tables', payload).then(r => r.data),
onSuccess: () => {
toast.success('Τραπέζια εισήχθησαν επιτυχώς')
setTablesModal(false)
setTablesPayload(null)
qc.invalidateQueries({ queryKey: ['system-stats'] })
qc.invalidateQueries({ queryKey: ['tables'] })
},
onError: (err) => toast.error(err?.response?.data?.detail ?? 'Σφάλμα εισαγωγής'),
})
function handleExport(bundle) {
client.get(`/api/data-transfer/export/${bundle}`, { responseType: 'blob' })
.then(res => {
const disposition = res.headers['content-disposition'] ?? ''
const match = disposition.match(/filename="([^"]+)"/)
const filename = match ? match[1] : `xenia-${bundle}.json`
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
})
.catch(() => toast.error('Σφάλμα εξαγωγής'))
}
function handleFileSelect(bundle, e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result)
if (parsed.bundle !== bundle) {
toast.error(`Λάθος αρχείο. Αναμένεται αρχείο τύπου '${bundle}'.`)
return
}
if (bundle === 'catalog') {
const cats = parsed.data?.categories ?? []
const orphans = parsed.data?.uncategorized_products ?? []
const prodCount = cats.reduce((acc, c) => acc + (c.products?.length ?? 0), 0) + orphans.length
setCatalogSummary(`Πρόκειται να εισαγάγετε ${cats.length} κατηγορίες και ${prodCount} προϊόντα.`)
setCatalogPayload(parsed)
setCatalogModal(true)
} else {
const groups = parsed.data?.table_groups ?? []
const ungrouped = parsed.data?.ungrouped_tables ?? []
const tableCount = groups.reduce((acc, g) => acc + (g.tables?.length ?? 0), 0) + ungrouped.length
setTablesSummary(`Πρόκειται να εισαγάγετε ${groups.length} ζώνες και ${tableCount} τραπέζια.`)
setTablesPayload(parsed)
setTablesModal(true)
}
} catch {
toast.error('Μη έγκυρο αρχείο JSON')
}
}
reader.readAsText(file)
}
return (
<div className="card divide-y divide-gray-100">
<div className="px-5 py-4">
<h2 className="font-semibold text-gray-700">Εισαγωγή / Εξαγωγή Δεδομένων</h2>
<p className="text-xs text-gray-400 mt-0.5">
Εξάγετε τα δεδομένα σας σε αρχείο ή εισάγετε δεδομένα από άλλη εγκατάσταση.
</p>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Κατάλογος Προϊόντων</p>
<p className="text-xs text-gray-500">Κατηγορίες + Προϊόντα (χωρίς εκτυπωτές)</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => handleExport('catalog')} className="btn text-sm">
Εξαγωγή
</button>
<label className="btn text-sm cursor-pointer">
Εισαγωγή
<input type="file" accept=".json" className="hidden" onChange={(e) => handleFileSelect('catalog', e)} />
</label>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Τραπέζια &amp; Ζώνες</p>
<p className="text-xs text-gray-500">Ζώνες τραπεζιών + Τραπέζια</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => handleExport('tables')} className="btn text-sm">
Εξαγωγή
</button>
<label className="btn text-sm cursor-pointer">
Εισαγωγή
<input type="file" accept=".json" className="hidden" onChange={(e) => handleFileSelect('tables', e)} />
</label>
</div>
</div>
<DoubleConfirmImport
isOpen={catalogModal}
onClose={() => { setCatalogModal(false); setCatalogPayload(null) }}
onConfirm={() => catalogImportMut.mutateAsync(catalogPayload)}
title="Εισαγωγή Καταλόγου Προϊόντων"
summary={catalogSummary}
isPending={catalogImportMut.isPending}
/>
<DoubleConfirmImport
isOpen={tablesModal}
onClose={() => { setTablesModal(false); setTablesPayload(null) }}
onConfirm={() => tablesImportMut.mutateAsync(tablesPayload)}
title="Εισαγωγή Τραπεζιών &amp; Ζωνών"
summary={tablesSummary}
isPending={tablesImportMut.isPending}
/>
</div>
)
}
function QRModal({ url, onClose }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-2xl p-8 flex flex-col items-center gap-5 max-w-sm w-full mx-4"
onClick={e => e.stopPropagation()}
>
<h3 className="font-bold text-gray-800 text-lg">QR Σύνδεσης</h3>
<p className="text-xs text-gray-500 text-center break-all">{url}</p>
<div className="p-3 bg-white border border-gray-200 rounded-xl">
<QRCodeSVG value={url} size={220} includeMargin={false} />
</div>
<p className="text-xs text-gray-400 text-center">
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
</p>
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
</div>
</div>
)
}
function formatUptime(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h}ω ${m}λ ${s}δ`
}
export default function AppInfoTab() {
const user = useAuthStore(s => s.user)
const qc = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const [qrOpen, setQrOpen] = useState(false)
const { data: status, isLoading } = useQuery({
queryKey: ['system-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data),
refetchInterval: 30_000,
})
async function handleRefresh() {
setRefreshing(true)
try {
await client.post('/api/system/sync-license')
await Promise.all([
qc.invalidateQueries({ queryKey: ['system-status'] }),
qc.invalidateQueries({ queryKey: ['license-status'] }),
])
} catch {
// sync-license failure just means cloud was unreachable — still refresh local caches
await Promise.all([
qc.invalidateQueries({ queryKey: ['system-status'] }),
qc.invalidateQueries({ queryKey: ['license-status'] }),
])
} finally {
setRefreshing(false)
}
}
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return (
<div className="space-y-6">
{/* System info */}
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
<button
onClick={handleRefresh}
disabled={refreshing}
title="Ανανέωση κατάστασης"
className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-gray-200 bg-white text-gray-500 text-xs font-medium hover:bg-gray-50 hover:text-gray-700 transition-colors disabled:opacity-50"
>
<span className={refreshing ? 'animate-spin inline-block' : 'inline-block'}></span>
{refreshing ? 'Ανανέωση…' : 'Ανανέωση'}
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="text-gray-500">Uptime</div>
<div className="font-medium text-gray-800">{formatUptime(status?.uptime_seconds ?? 0)}</div>
<div className="text-gray-500">Έκδοση</div>
<div className="font-medium text-gray-800 flex items-center gap-2">
{status?.version ?? '—'}
{status?.latest_version && status.latest_version !== status.version && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
Διαθέσιμη {status.latest_version}
</span>
)}
{status?.latest_version && status.latest_version === status.version && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-700">
Ενημερωμένο
</span>
)}
</div>
<div className="text-gray-500">Άδεια χρήσης</div>
<div className={`font-medium ${status?.licensed ? 'text-green-700' : 'text-red-600'}`}>
{status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
</div>
<div className="text-gray-500">Κατάσταση</div>
<div className={`font-medium ${
status?.locked ? 'text-red-600'
: status?.lock_pending ? 'text-amber-600'
: 'text-green-700'
}`}>
{status?.locked ? 'Κλειδωμένο'
: status?.lock_pending ? 'Εκκρεμεί Κλείδωμα'
: 'Λειτουργικό'}
</div>
{status?.expires_at && (
<>
<div className="text-gray-500">Λήξη άδειας</div>
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</div>
</>
)}
{status?.waiter_domain && (
<>
<div className="text-gray-500">Waiter Domain</div>
<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>
<button
onClick={() => setQrOpen(true)}
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>
</div>
<TimezoneSection />
<StatsSection />
<DataTransferSection />
{user?.role === 'sysadmin' && (
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>
<div className="flex gap-3">
<button onClick={() => client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
className="btn btn-primary text-sm">Ξεκλείδωμα</button>
<button onClick={() => client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
className="btn btn-danger text-sm">Κλείδωμα</button>
</div>
</div>
)}
{qrOpen && status?.waiter_domain && (
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
)}
</div>
)
}

View File

@@ -0,0 +1,494 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { DEFAULT_COLOURS } from '../../../store/tableColourStore'
import client from '../../../api/client'
import toast from 'react-hot-toast'
// ─── Colour slot metadata ────────────────────────────────────────────────────
const SLOTS = [
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
]
const STATUSES = [
{ key: 'free', label: 'Ελεύθερο' },
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
{ key: 'paid', label: 'Πληρωμένο' },
]
const STATUS_LABELS_MOCK = {
free: 'ΕΛΕΥΘΕΡΟ',
open: 'ΑΝΟΙΧΤΟ',
mine: 'ΔΙΚΟ ΜΟΥ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
paid: 'ΠΛΗΡΩΜΕΝΟ',
}
// Quick-suggest palettes per slot type
const QUICK_SWATCHES = {
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
}
// ─── Color picker modal ──────────────────────────────────────────────────────
// Parse any css colour string into { hex, alpha }.
// Handles: #rrggbb, #rgb, rgba(r,g,b,a), rgb(r,g,b)
function parseColour(v) {
if (!v) return { hex: '#ffffff', alpha: 1 }
const s = v.trim()
// rgba / rgb
const rgbaMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/)
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0')
const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0')
const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0')
const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1
return { hex: `#${r}${g}${b}`, alpha: Math.min(1, Math.max(0, a)) }
}
// #rgb shorthand
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
const [, r, g, b] = s
return { hex: `#${r}${r}${g}${g}${b}${b}`, alpha: 1 }
}
// #rrggbb
if (/^#[0-9a-fA-F]{6}$/.test(s)) return { hex: s, alpha: 1 }
return { hex: '#ffffff', alpha: 1 }
}
function buildColour(hex, alpha) {
if (alpha >= 1) return hex
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha.toFixed(2)})`
}
function ColourPickerModal({ value, onClose, onChange, slot }) {
const parsed = parseColour(value)
const [hex, setHex] = useState(parsed.hex)
const [alpha, setAlpha] = useState(parsed.alpha)
// keep parent in sync whenever hex or alpha changes
useEffect(() => { onChange(buildColour(hex, alpha)) }, [hex, alpha])
function commitSwatch(v) {
const p = parseColour(v)
setHex(p.hex)
setAlpha(p.alpha)
}
const preview = buildColour(hex, alpha)
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
onClick={onClose}
>
<div
style={{
background: '#fff', borderRadius: 20, padding: 28, width: '100%', maxWidth: 400,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
</div>
{/* Preview swatch — checkerboard behind so alpha is visible */}
<div style={{
width: '100%', height: 56, borderRadius: 12, marginBottom: 20,
border: '1px solid #e5e7eb', overflow: 'hidden', position: 'relative',
backgroundImage: 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)',
backgroundSize: '12px 12px',
backgroundPosition: '0 0,0 6px,6px -6px,-6px 0',
}}>
<div style={{
position: 'absolute', inset: 0, background: preview,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontFamily: 'monospace', color: alpha > 0.5 ? '#fff' : '#374151',
textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none',
}}>
{preview}
</div>
</div>
{/* Colour picker + hex input */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Χρώμα</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
type="color"
value={hex}
onChange={e => setHex(e.target.value)}
style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }}
/>
<input
type="text"
value={hex}
onChange={e => {
const v = e.target.value
setHex(v)
}}
spellCheck={false}
style={{
flex: 1, height: 40, borderRadius: 8, border: '1px solid #e5e7eb',
padding: '0 12px', fontSize: 13, fontFamily: 'monospace', color: '#111827',
}}
/>
</div>
</div>
{/* Opacity slider — always visible */}
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>Διαφάνεια</div>
<div style={{ fontSize: 12, fontFamily: 'monospace', color: '#6b7280' }}>{Math.round(alpha * 100)}%</div>
</div>
{/* Gradient track so you can see what you're dragging */}
<div style={{
position: 'relative', height: 28,
background: `linear-gradient(to right, transparent, ${hex})`,
borderRadius: 8, border: '1px solid #e5e7eb',
backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%),linear-gradient(to right,transparent,${hex})`,
backgroundSize: '10px 10px,10px 10px,10px 10px,10px 10px,100% 100%',
backgroundPosition: '0 0,0 5px,5px -5px,-5px 0,0 0',
}}>
<input
type="range"
min={0} max={1} step={0.01}
value={alpha}
onChange={e => setAlpha(parseFloat(e.target.value))}
style={{
position: 'absolute', inset: 0, width: '100%', height: '100%',
opacity: 0, cursor: 'pointer', margin: 0,
}}
/>
{/* thumb indicator */}
<div style={{
position: 'absolute', top: '50%', transform: 'translate(-50%,-50%)',
left: `${alpha * 100}%`,
width: 20, height: 20, borderRadius: '50%',
background: preview, border: '2px solid #fff',
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}} />
</div>
</div>
{/* Quick swatches */}
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Γρήγορη επιλογή</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(QUICK_SWATCHES[slot] || []).map(c => {
const p = parseColour(c)
const built = buildColour(p.hex, p.alpha)
return (
<button
key={c}
title={c}
onClick={() => commitSwatch(c)}
style={{
width: 36, height: 36, borderRadius: 8,
backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)`,
backgroundSize: '8px 8px',
backgroundPosition: '0 0,0 4px,4px -4px,-4px 0',
position: 'relative', overflow: 'hidden',
border: built === preview ? '3px solid #3758c9' : '2px solid #e5e7eb',
cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
}}
>
<div style={{ position: 'absolute', inset: 0, background: c }} />
</button>
)
})}
</div>
</div>
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid #f3f4f6', display: 'flex', gap: 10 }}>
<button
onClick={onClose}
style={{
flex: 1, height: 40, borderRadius: 10, border: '1px solid #e5e7eb',
background: '#f9fafb', fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}
>Κλείσιμο</button>
</div>
</div>
</div>
)
}
// ─── Single colour slot row ──────────────────────────────────────────────────
function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
<button
onClick={() => onOpen(mode, status, slotKey, value)}
style={{
width: 44, height: 28, borderRadius: 8, background: value,
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'monospace', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</div>
</div>
</div>
)
}
// ─── Mini mock table card (for preview) ──────────────────────────────────────
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
return (
<div style={{
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
position: 'relative', flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
{/* Table name + group */}
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{
fontSize: 17, fontWeight: 800, color: cfg.nameText,
lineHeight: 1, letterSpacing: -0.5,
}}>{mockName}</span>
<span style={{
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
color: cfg.nameText + '80',
textTransform: 'uppercase',
}}>{groupName}</span>
</div>
{/* Status badge — tight equal padding on all sides */}
<div style={{
position: 'absolute', bottom: 7, left: 7,
background: cfg.badgeBg,
borderRadius: 4, padding: '2px 5px',
lineHeight: 1,
}}>
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
{label}
</span>
</div>
</div>
)
}
// ─── Preview panel (6 mock cards per theme) ──────────────────────────────────
function PreviewPanel({ colours, mode }) {
const isDark = mode === 'dark'
const panelBg = isDark ? '#0d1520' : '#f1f5f9'
const panelLabel = isDark ? '🌙 Προεπισκόπηση σκοτεινού θέματος' : '☀️ Προεπισκόπηση φωτεινού θέματος'
const labelCol = isDark ? '#94a3b8' : '#64748b'
const mockCards = [
{ status: 'free', name: 'TABLE 1', group: 'ΜΕΣΑ' },
{ status: 'open', name: 'TABLE 2', group: 'ΜΕΣΑ' },
{ status: 'mine', name: 'TABLE 3', group: 'ΜΕΣΑ' },
{ status: 'partially_paid', name: 'TABLE 4', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
{ status: 'paid', name: 'TABLE 5', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
{ status: 'free', name: 'TABLE 6', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
]
return (
<div style={{
background: panelBg, borderRadius: 16, padding: 16,
border: '1px solid ' + (isDark ? '#253245' : '#cbd5e1'),
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: labelCol, marginBottom: 12, letterSpacing: 0.3 }}>
{panelLabel}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{mockCards.map((mc, i) => (
<MockCard
key={i}
cfg={colours[mode][mc.status]}
label={STATUS_LABELS_MOCK[mc.status]}
mockName={mc.name}
groupName={mc.group}
/>
))}
</div>
</div>
)
}
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
function StatusBlock({ mode, status, label, colours, onOpen }) {
const cfg = colours[mode][status]
return (
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 88, flexShrink: 0 }}>
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
{SLOTS.map(slot => (
<ColourSlotRow
key={slot.key}
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
))}
</div>
</div>
)
}
// ─── Mode section (light or dark) ────────────────────────────────────────────
function ModeSection({ mode, colours, onOpen }) {
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
return (
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111827', marginBottom: 14 }}>{label}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{STATUSES.map(s => (
<StatusBlock
key={s.key}
mode={mode}
status={s.key}
label={s.label}
colours={colours}
onOpen={onOpen}
/>
))}
</div>
</div>
)
}
// ─── Main tab ────────────────────────────────────────────────────────────────
export default function ColoursTab() {
const [colours, setColours] = useState(DEFAULT_COLOURS)
const [modal, setModal] = useState(null) // { mode, status, slot, value }
const [saving, setSaving] = useState(false)
const saveTimer = useRef(null)
// Load from backend on mount
useEffect(() => {
client.get('/api/settings/').then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) {
try { setColours(JSON.parse(raw)) } catch {}
}
})
}, [])
// Debounced save to backend — 600 ms after last change
const saveToBackend = useCallback((next) => {
clearTimeout(saveTimer.current)
setSaving(true)
saveTimer.current = setTimeout(() => {
client.put('/api/settings/ui.table_colours', { value: JSON.stringify(next) })
.then(() => setSaving(false))
.catch(() => { toast.error('Σφάλμα αποθήκευσης χρωμάτων'); setSaving(false) })
}, 600)
}, [])
function setColour(mode, status, slot, value) {
setColours(prev => {
const next = {
...prev,
[mode]: {
...prev[mode],
[status]: { ...prev[mode][status], [slot]: value },
},
}
saveToBackend(next)
return next
})
}
function openModal(mode, status, slot, value) {
setModal({ mode, status, slot, value })
}
function handleChange(value) {
setColour(modal.mode, modal.status, modal.slot, value)
setModal(m => ({ ...m, value }))
}
function handleReset() {
if (window.confirm('Επαναφορά όλων των χρωμάτων στις προεπιλογές; Δεν μπορεί να αναιρεθεί.')) {
setColours(DEFAULT_COLOURS)
saveToBackend(DEFAULT_COLOURS)
}
}
return (
<div>
<div className="card" style={{ padding: 24 }}>
{saving && <p style={{ fontSize: 12, color: '#9ca3af', marginBottom: 16 }}>Αποθήκευση</p>}
{/* Live previews side by side */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 32 }}>
<PreviewPanel colours={colours} mode="light" />
<PreviewPanel colours={colours} mode="dark" />
</div>
{/* Light + Dark mode settings */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
<ModeSection mode="light" colours={colours} onOpen={openModal} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
</div>
</div>
{/* Reset all button at bottom */}
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
height: 40, padding: '0 20px', borderRadius: 10,
border: '1.5px solid #fca5a5', background: '#fff5f5',
color: '#dc2626', fontSize: 14, fontWeight: 600, cursor: 'pointer',
}}
>
Επαναφορά προεπιλογών
</button>
</div>
{/* Colour picker modal */}
{modal && (
<ColourPickerModal
value={modal.value}
slot={modal.slot}
onClose={() => setModal(null)}
onChange={handleChange}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
function Toggle({ checked, onChange, disabled }) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 44, height: 24, borderRadius: 999, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
background: checked ? '#dc2626' : '#d1d5db',
position: 'relative', transition: 'background 150ms', flexShrink: 0, opacity: disabled ? 0.5 : 1,
}}
>
<span style={{
position: 'absolute', top: 3, left: checked ? 23 : 3,
width: 18, height: 18, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)
}
export default function DevelopmentTab() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
})
const mutation = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const spoofOn = settings?.['dev.spoof_printing']?.value === 'true'
function toggleSpoof(val) {
mutation.mutate({ key: 'dev.spoof_printing', value: val ? 'true' : 'false' })
toast.success(val ? 'Spoof mode ενεργό — εκτυπωτές σιωπηλοί' : 'Spoof mode ανενεργό — εκτυπωτές ενεργοί')
}
if (isLoading) return <div className="flex items-center justify-center h-48 text-gray-400 text-sm">Φόρτωση</div>
return (
<div className="space-y-4">
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-800">
Αυτές οι ρυθμίσεις προορίζονται μόνο για δοκιμές. Μην τις αφήνετε ενεργές σε παραγωγικό περιβάλλον.
</div>
<div className="card divide-y divide-gray-100">
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div>
<p className="font-semibold text-gray-700">Spoof Printer Mode</p>
<p className="text-xs text-gray-400 mt-0.5">
Όλες οι εκτυπώσεις απορρίπτονται αθόρυβα. Οι συσκευές συμπεριφέρονται σαν η εκτύπωση να πέτυχε.
</p>
</div>
<Toggle checked={spoofOn} onChange={toggleSpoof} disabled={mutation.isPending} />
</div>
</div>
{spoofOn && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 font-medium">
Spoof mode ενεργό οι εκτυπωτές είναι σιωπηλοί.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,338 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
function Toggle({ checked, onChange, disabled }) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 flex-shrink-0 disabled:opacity-50 ${
checked ? 'bg-sky-500' : 'bg-slate-200'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-all duration-200 ${
checked ? 'left-6' : 'left-1'
}`} />
</button>
)
}
function SectionCard({ title, description, children, action }) {
return (
<div className="card divide-y divide-gray-100">
<div className="flex items-center justify-between px-5 py-4">
<div>
<h2 className="font-semibold text-gray-700">{title}</h2>
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
</div>
{action}
</div>
{children}
</div>
)
}
function OptionRow({ label, description, children }) {
return (
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800">{label}</p>
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
)
}
function ShiftSettingsSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
return (
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<>
<OptionRow label="Αυτόματη Έναρξη Βάρδιας" description="Οι σερβιτόροι μπορούν να ξεκινούν μόνοι τους τη βάρδια τους">
<Toggle checked={selfStart === 'true'} onChange={() => toggle('shifts.waiter_self_start', selfStart)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους">
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
</OptionRow>
</>
)}
</SectionCard>
)
}
// ─── Flag definitions ─────────────────────────────────────────────────────────
const FLAG_COLORS = [
'#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6',
'#8b5cf6', '#ec4899', '#06b6d4', '#6b7280', '#dc2626',
]
const RESTAURANT_EMOJIS = [
'🧹', '⭐', '📝', '🛎️', '💎',
'🤵🏻', '🔒', '🛒', '🗣️', '⛔',
'🥳', '🎂', '🎉', '🍰', '🤩',
'☕', '🥂', '🍾', '🍹', '💦',
'🍖', '🥩', '🍽️', '🥓', '🍳',
'♿', '👶', '🤬', '🐶', '🐱',
'🚧', '🔥', '❄️', '⏳', '⚠️',
]
function EmojiPicker({ value, onChange }) {
const [open, setOpen] = useState(false)
return (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setOpen(o => !o)} style={{
width: 60, height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', fontSize: 20, textAlign: 'center', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{value || ''}</button>
{open && (
<div style={{
position: 'absolute', top: '110%', left: 0, zIndex: 200,
background: 'white', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 8px 24px rgba(0,0,0,0.12)', padding: 8,
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 2, width: 180,
}}>
{RESTAURANT_EMOJIS.map(e => (
<button key={e} type="button" onClick={() => { onChange(e); setOpen(false) }} style={{
fontSize: 20, background: value === e ? '#eff3ff' : 'none',
border: 'none', borderRadius: 6, padding: '4px 0', cursor: 'pointer',
}}>{e}</button>
))}
<button type="button" onClick={() => { onChange(''); setOpen(false) }} style={{
fontSize: 11, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', borderRadius: 6,
}}> clear</button>
</div>
)}
</div>
)
}
function FlagDefsSection() {
const qc = useQueryClient()
const [editingId, setEditingId] = useState(null)
const [editForm, setEditForm] = useState({})
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280', text_color: null })
const [showNew, setShowNew] = useState(false)
const { data: flags = [], isLoading } = useQuery({
queryKey: ['flag-defs'],
queryFn: () => client.get('/api/flags/defs?include_inactive=true').then(r => r.data),
staleTime: 30_000,
})
const createMut = useMutation({
mutationFn: (body) => client.post('/api/flags/defs', body),
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280', text_color: null }) },
onError: () => toast.error('Σφάλμα'),
})
const updateMut = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/flags/defs/${id}`, body),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setEditingId(null) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const toggleMut = useMutation({
mutationFn: (id) => client.patch(`/api/flags/defs/${id}/toggle-active`),
onSuccess: (res) => { toast.success(res.data.is_active ? 'Ενεργοποιήθηκε' : 'Απενεργοποιήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
onError: () => toast.error('Σφάλμα'),
})
const deleteMut = useMutation({
mutationFn: (id) => client.delete(`/api/flags/defs/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
function startEdit(flag) {
setEditingId(flag.id)
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', text_color: flag.text_color || null, sort_order: flag.sort_order })
}
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
const newRowButton = (
<button onClick={() => setShowNew(v => !v)} style={{
height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}>+ Νέα</button>
)
return (
<SectionCard title="Σημάνσεις Τραπεζιών" description="Χρησιμοποιούνται για να επισημαίνετε καταστάσεις στα τραπέζια" action={newRowButton}>
{showNew && (
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', borderTop: '1px solid #f4f4f2' }}>
<EmojiPicker value={newForm.emoji} onChange={v => setNewForm(f => ({ ...f, emoji: v }))} />
<input placeholder="Όνομα σημαίας" value={newForm.name} onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))}
style={{ flex: 1, minWidth: 160, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
<div style={{ display: 'flex', gap: 4 }}>
{FLAG_COLORS.map(c => (
<button key={c} onClick={() => setNewForm(f => ({ ...f, color: c }))}
style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
))}
</div>
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Χρώμα γραφής:</span>
{[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => (
<button key={opt.label + opt.text} onClick={() => setNewForm(f => ({ ...f, text_color: opt.val }))}
style={{ width: 28, height: 28, borderRadius: 6, background: opt.bg, color: opt.text, fontSize: 14, fontWeight: 700, border: newForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>{opt.label}</button>
))}
</div>
<button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
</div>
)}
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση</p>}
{!isLoading && flags.length === 0 && (
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Δεν υπάρχουν σημαίες ακόμα.</p>
)}
{flags.map(flag => (
<div key={flag.id} style={{ ...rowStyle, opacity: flag.is_active ? 1 : 0.45 }}>
{editingId === flag.id ? (
<div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<EmojiPicker value={editForm.emoji} onChange={v => setEditForm(f => ({ ...f, emoji: v }))} />
<input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))}
style={{ flex: 1, minWidth: 120, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
<div style={{ display: 'flex', gap: 3 }}>
{FLAG_COLORS.map(c => (
<button key={c} onClick={() => setEditForm(f => ({ ...f, color: c }))}
style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
))}
</div>
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
{[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => (
<button key={opt.text} onClick={() => setEditForm(f => ({ ...f, text_color: opt.val }))}
style={{ width: 24, height: 24, borderRadius: 6, background: editForm.color || '#6b7280', color: opt.text, fontSize: 13, fontWeight: 700, border: editForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>Α</button>
))}
</div>
<button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}></button>
<button onClick={() => setEditingId(null)}
style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}></button>
</div>
) : (
<>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: flag.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0 }}>
{flag.emoji || '🏷️'}
</div>
<span style={{ flex: 1, fontSize: 14, fontWeight: 500, color: '#111315' }}>{flag.name}</span>
{!flag.is_active && <span style={{ fontSize: 11, color: '#9ca3af', fontStyle: 'italic' }}>Ανενεργή</span>}
<button onClick={() => startEdit(flag)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία</button>
<button
onClick={() => toggleMut.mutate(flag.id)}
disabled={toggleMut.isPending}
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fed7aa', background: '#fff7ed', fontSize: 12, cursor: 'pointer', color: '#c2410c' }}
>{flag.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}</button>
<button
onClick={() => {
if (window.confirm(`Να διαγραφεί οριστικά η σήμανση "${flag.name}";`)) deleteMut.mutate(flag.id)
}}
disabled={deleteMut.isPending}
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}
>Διαγραφή</button>
</>
)}
</div>
))}
</SectionCard>
)
}
// ─── Quick message templates ──────────────────────────────────────────────────
function QuickTemplatesSection() {
const qc = useQueryClient()
const [editingId, setEditingId] = useState(null)
const [editBody, setEditBody] = useState('')
const [newBody, setNewBody] = useState('')
const [showNew, setShowNew] = useState(false)
const { data: templates = [], isLoading } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
staleTime: 30_000,
})
const createMut = useMutation({
mutationFn: (body) => client.post('/api/messages/templates', body),
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setShowNew(false); setNewBody('') },
onError: () => toast.error('Σφάλμα'),
})
const updateMut = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/messages/templates/${id}`, { body }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setEditingId(null) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteMut = useMutation({
mutationFn: (id) => client.delete(`/api/messages/templates/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }) },
onError: () => toast.error('Σφάλμα'),
})
const newButton = (
<button onClick={() => setShowNew(v => !v)} style={{
height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}>+ Νέο</button>
)
return (
<SectionCard title="Γρήγορα Μηνύματα" description="Πρότυπα μηνυμάτων για γρήγορη αποστολή στο προσωπικό" action={newButton}>
{showNew && (
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', gap: 10, alignItems: 'center', borderTop: '1px solid #f4f4f2' }}>
<input placeholder="Κείμενο μηνύματος…" value={newBody} onChange={e => setNewBody(e.target.value)}
style={{ flex: 1, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
<button onClick={() => createMut.mutate({ body: newBody, sort_order: templates.length + 1 })}
disabled={!newBody.trim() || createMut.isPending}
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
</div>
)}
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση</p>}
{!isLoading && templates.length === 0 && (
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Δεν υπάρχουν πρότυπα ακόμα.</p>
)}
{templates.map((t, idx) => (
<div key={t.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }}>
<span style={{ width: 22, fontSize: 12, color: '#9ca3af', fontWeight: 600, flexShrink: 0 }}>{idx + 1}.</span>
{editingId === t.id ? (
<>
<input value={editBody} onChange={e => setEditBody(e.target.value)}
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
<button onClick={() => updateMut.mutate({ id: t.id, body: editBody })} disabled={updateMut.isPending}
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}></button>
<button onClick={() => setEditingId(null)}
style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}></button>
</>
) : (
<>
<span style={{ flex: 1, fontSize: 14, color: '#111315' }}>{t.body}</span>
<button onClick={() => { setEditingId(t.id); setEditBody(t.body) }}
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία</button>
<button onClick={() => deleteMut.mutate(t.id)}
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή</button>
</>
)}
</div>
))}
</SectionCard>
)
}
export default function OperationTab() {
return (
<div className="space-y-6">
<ShiftSettingsSection />
<FlagDefsSection />
<QuickTemplatesSection />
</div>
)
}

View File

@@ -0,0 +1,688 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
// ── Font option definitions ────────────────────────────────────────────────
// Value encodes: "SIZE:BOLD:CAPS"
// SIZE: ESC ! base byte — 0=normal, 16=tall, 32=wide, 48=tall+wide
// BOLD: 0|1 CAPS: 0|1
const FONT_SIZE_OPTIONS = [
{ size: '0', label: 'Μικρά' },
{ size: '16', label: 'Ψηλά' },
{ size: '32', label: 'Πλατιά' },
{ size: '48', label: 'Ψηλά και Πλατιά' },
]
function encodeFont(size, bold, caps) {
return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
}
function decodeFont(val) {
if (!val) return { size: '0', bold: false, caps: false }
const [size, bold, caps] = val.split(':')
return { size: size ?? '0', bold: bold === '1', caps: caps === '1' }
}
const DIVIDER_OPTIONS = [
{ value: 'dash', label: 'Παύλες ( - )', chars: '-------------------' },
{ value: 'equals', label: 'Ίσον ( = )', chars: '===================' },
{ value: 'star', label: 'Αστερίσκοι ( * )', chars: '*******************' },
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
]
const FONT_DEFAULTS = {
'print.font_order_number': '48:1:0',
'print.font_meta': '0:0:0',
'print.font_item_name': '16:1:0',
'print.font_quick': '0:0:0',
'print.font_pref': '0:0:0',
'print.font_extra': '0:0:0',
'print.font_ingredient': '0:0:0',
'print.font_item_note': '0:0:0',
'print.font_order_note': '0:1:0',
'print.divider_style': 'dash',
'print.ticket_mode': 'detailed',
}
// ── Preview ────────────────────────────────────────────────────────────────
const PREVIEW_W = 200
const PREVIEW_H = 50
const sizeStyle = {
'0': { fontSize: 13, scaleY: 1, scaleX: 1 },
'16': { fontSize: 13, scaleY: 1.9, scaleX: 1 },
'32': { fontSize: 13, scaleY: 1, scaleX: 1.9 },
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
}
function FontPreview({ size, bold, caps }) {
const s = sizeStyle[size] ?? sizeStyle['0']
return (
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
<span style={{
color: '#f5f5f5',
fontFamily: 'Arial, Helvetica, sans-serif',
fontSize: s.fontSize,
fontWeight: bold ? 800 : 400,
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
transformOrigin: 'center',
whiteSpace: 'nowrap',
display: 'block',
}}>
{caps ? 'SAMPLE' : 'Sample'}
</span>
</div>
)
}
// ── Toggle button (shared) ─────────────────────────────────────────────────
function ToggleBtn({ active, onClick, disabled, label }) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
border: `1.5px solid ${active ? '#3758c9' : '#dfe2e6'}`,
background: active ? '#eff3ff' : 'white',
color: active ? '#3758c9' : '#6b7280',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: `2px solid ${active ? '#3758c9' : '#9ca3af'}`,
background: active ? '#3758c9' : 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{active && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}></span>}
</span>
{label}
</button>
)
}
// ── Single font row ────────────────────────────────────────────────────────
function FontRow({ field, value, onChange, isPending, nested = false }) {
const { size, bold, caps } = decodeFont(value)
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold, caps)) }
function handleBold() { onChange(field.key, encodeFont(size, !bold, caps)) }
function handleCaps() { onChange(field.key, encodeFont(size, bold, !caps)) }
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: nested ? '10px 20px 10px 36px' : '14px 20px',
borderBottom: '1px solid #f4f4f2',
background: nested ? '#fafafa' : 'white',
}}>
{nested && (
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}></span>
)}
{/* Label */}
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
{field.label}
</span>
{field.sub && (
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
)}
</div>
{/* Size dropdown */}
<select
value={size}
onChange={handleSize}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{FONT_SIZE_OPTIONS.map(o => (
<option key={o.size} value={o.size}>{o.label}</option>
))}
</select>
{/* Bold toggle */}
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
{/* Caps toggle */}
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
{/* Preview */}
<FontPreview size={size} bold={bold} caps={caps} />
</div>
)
}
// ── Subgroup header row ────────────────────────────────────────────────────
function SubgroupHeader({ label }) {
return (
<div style={{
padding: '8px 20px 6px',
borderBottom: '1px solid #f4f4f2',
background: '#f9fafb',
}}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
{label}
</span>
</div>
)
}
// ── Divider row ────────────────────────────────────────────────────────────
function DividerRow({ value, onChange, isPending }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 20px',
}}>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
Στυλ Διαχωριστικού
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>Ανάμεσα στις ενότητες κάθε ticket</span>
</div>
<select
value={value}
onChange={e => onChange('print.divider_style', e.target.value)}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{DIVIDER_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* spacer to align with bold+caps column */}
<div style={{ width: 194, flexShrink: 0 }} />
{/* Preview */}
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{value === 'empty'
? <span style={{ color: '#6b7280', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif' }}>(κενή γραμμή)</span>
: <span style={{ color: '#f5f5f5', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif', letterSpacing: 2 }}>
{DIVIDER_OPTIONS.find(o => o.value === value)?.chars}
</span>
}
</div>
</div>
)
}
// ── Ticket mode section ────────────────────────────────────────────────────
function TicketModeSection({ value, onChange, isPending, printers }) {
const [selectedPrinter, setSelectedPrinter] = useState(null)
const [printing, setPrinting] = useState(false)
// Auto-select first active printer
useEffect(() => {
if (printers.length > 0 && !selectedPrinter) {
const first = printers.find(p => p.is_active) ?? printers[0]
setSelectedPrinter(first.id)
}
}, [printers])
async function handleTestOrder() {
if (!selectedPrinter) return
setPrinting(true)
try {
const res = await client.post(`/api/system/printers/test-order?printer_id=${selectedPrinter}`)
if (res.data.success) toast.success('Test order στάλθηκε!')
else toast.error(`Σφάλμα: ${res.data.error}`)
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setPrinting(false)
}
}
return (
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Τύπος Εκτύπωσης</h2>
<p className="text-xs text-gray-400 mt-0.5">
Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας.
</p>
</div>
<div style={{ display: 'flex', gap: 12, padding: '16px 20px', flexWrap: 'wrap' }}>
{[
{
key: 'detailed',
title: 'Αναλυτικό',
desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.',
},
{
key: 'compact',
title: 'Συμπαγές',
desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.',
},
].map(opt => {
const active = value === opt.key
return (
<button
key={opt.key}
onClick={() => onChange('print.ticket_mode', opt.key)}
disabled={isPending}
style={{
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
borderRadius: 10, cursor: 'pointer',
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
background: active ? '#eff3ff' : 'white',
}}
>
<div style={{ fontSize: 14, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 4 }}>
{opt.title}
</div>
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>{opt.desc}</div>
</button>
)
})}
{/* Test order button */}
<button
onClick={handleTestOrder}
disabled={printing || !selectedPrinter}
style={{
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
borderRadius: 10, cursor: printing || !selectedPrinter ? 'default' : 'pointer',
border: '2px solid #e5e7eb',
background: printing ? '#f9fafb' : 'white',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: printing ? '#9ca3af' : '#111315', marginBottom: 4 }}>
{printing ? 'Εκτύπωση…' : 'Δοκιμαστική Εκτύπωση'}
</div>
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
Εκτυπώνει fake παραγγελία με όλους τους τύπους επιλογών για προεπισκόπηση ρυθμίσεων.
</div>
</div>
{printers.length > 0 && (
<div style={{ marginTop: 10 }} onClick={e => e.stopPropagation()}>
<select
value={selectedPrinter ?? ''}
onChange={e => setSelectedPrinter(Number(e.target.value))}
disabled={printing}
style={{
width: '100%', height: 32, borderRadius: 6,
border: '1px solid #dfe2e6', background: 'white',
padding: '0 8px', fontSize: 12, color: '#374151', cursor: 'pointer',
}}
>
{printers.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
))}
</select>
</div>
)}
{printers.length === 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444' }}>
Δεν υπάρχουν εκτυπωτές
</div>
)}
</button>
</div>
</div>
)
}
// ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
function set(k, v) { setForm(f => ({ ...f, [k]: v })) }
return (
<div style={{
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 10,
padding: '16px 20px', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΟΝΟΜΑ</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Κουζίνα" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 130px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>IP ADDRESS</label>
<input value={form.ip_address} onChange={e => set('ip_address', e.target.value)}
placeholder="10.98.20.25" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 80px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
type="number" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
style={btnPrimary}>Αποθήκευση</button>
<button onClick={onCancel} style={btnSecondary}>Άκυρο</button>
</div>
</div>
)
}
const inputStyle = {
height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white',
padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%',
}
const btnPrimary = {
height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white',
border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}
const btnSecondary = {
height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151',
}
const btnDanger = {
height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2',
background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626',
}
function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) {
const [reachable, setReachable] = useState(null)
useEffect(() => {
let cancelled = false
client.get('/api/system/status').then(r => {
if (cancelled) return
const match = r.data.printers?.find(p => p.id === printer.id)
if (match) setReachable(match.reachable)
}).catch(() => {})
return () => { cancelled = true }
}, [printer.id])
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 20px', borderBottom: '1px solid #f4f4f2',
opacity: printer.is_active ? 1 : 0.5,
flexWrap: 'wrap',
}}>
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
style={{
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
background: printer.is_active ? '#16a34a' : '#d1d5db', position: 'relative', transition: 'background 150ms',
}}>
<span style={{
position: 'absolute', top: 3, left: printer.is_active ? 21 : 3,
width: 16, height: 16, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
<div style={{ flex: 1, minWidth: 120 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
{printer.ip_address}:{printer.port}
</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
</div>
<span style={{
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
color: reachable === null ? '#9ca3af' : reachable ? '#16a34a' : '#dc2626',
}}>
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
</span>
<button onClick={() => onTest(printer.id)} disabled={testPending}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Test Print
</button>
<button onClick={() => onEdit(printer)}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Επεξεργασία
</button>
<button onClick={() => onDelete(printer.id)} style={{ ...btnDanger, flexShrink: 0 }}>
Διαγραφή
</button>
</div>
)
}
function PrintersSection() {
const qc = useQueryClient()
const [showNew, setShowNew] = useState(false)
const [editingId, setEditingId] = useState(null)
const { data: printers = [], isLoading } = useQuery({
queryKey: ['printers-all'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 15_000,
})
const createMut = useMutation({
mutationFn: body => client.post('/api/system/printers', body),
onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) },
onError: () => toast.error('Σφάλμα δημιουργίας'),
})
const updateMut = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteMut = useMutation({
mutationFn: id => client.delete(`/api/system/printers/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) },
onError: () => toast.error('Σφάλμα διαγραφής'),
})
const testMut = useMutation({
mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`),
onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`),
onError: () => toast.error('Σφάλμα επικοινωνίας'),
})
function handleToggle(printer) {
updateMut.mutate({ id: printer.id, is_active: !printer.is_active })
}
return (
<div className="card divide-y divide-gray-100">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
<div>
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
<p className="text-xs text-gray-400 mt-0.5">Διαχείριση εκτυπωτών του συστήματος</p>
</div>
<button onClick={() => { setShowNew(v => !v); setEditingId(null) }} style={btnSecondary}>
+ Νέος εκτυπωτής
</button>
</div>
{showNew && (
<div style={{ padding: '12px 20px' }}>
<PrinterForm
onSave={form => createMut.mutate(form)}
onCancel={() => setShowNew(false)}
isPending={createMut.isPending}
/>
</div>
)}
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση</p>}
{!isLoading && printers.length === 0 && !showNew && (
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
Δεν υπάρχουν εκτυπωτές ακόμα.
</p>
)}
{printers.map(printer => (
editingId === printer.id ? (
<div key={printer.id} style={{ padding: '12px 20px', borderBottom: '1px solid #f4f4f2' }}>
<PrinterForm
initial={printer}
onSave={form => updateMut.mutate({ id: printer.id, ...form })}
onCancel={() => setEditingId(null)}
isPending={updateMut.isPending}
/>
</div>
) : (
<PrinterRow
key={printer.id}
printer={printer}
onEdit={p => { setEditingId(p.id); setShowNew(false) }}
onDelete={id => deleteMut.mutate(id)}
onTest={id => testMut.mutate(id)}
onToggle={handleToggle}
testPending={testMut.isPending}
/>
)
))}
</div>
)
}
// ── Font groups definition ─────────────────────────────────────────────────
const FONT_GROUPS = [
{
group: 'Αριθμός Παραγγελίας',
fields: [
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: '"Παραγγελια #42" — η επικεφαλίδα του ticket' },
],
},
{
group: 'Επικεφαλίδα Ticket',
fields: [
{ key: 'print.font_meta', label: 'Τραπέζι · Σερβιτόρος · Ώρα', sub: 'Γραμμές ταυτότητας κάτω από τον αριθμό' },
],
},
{
group: 'Αντικείμενα',
fields: [
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Το κυρίως πιάτο/ποτό — γραμμή dot-leader' },
{ key: 'print.font_quick', label: '* Quick Options', sub: 'Γρήγορες επιλογές ( * )' },
{ key: 'print.font_pref', label: '> Προτιμήσεις', sub: 'Επιλογές preference sets ( > )' },
{ key: 'print.font_extra', label: '+ Extras', sub: 'Πρόσθετα / τροποποιητές ( + )' },
{ key: 'print.font_ingredient', label: '- Αφαιρέσεις', sub: 'ΧΩΡΙΣ: συστατικά ( - )' },
],
},
{
group: 'Σημειώσεις',
fields: [
{ key: 'print.font_item_note', label: '(!) Σημείωση Αντικειμένου', sub: 'Free-text σημείωση ανά πιάτο' },
{ key: 'print.font_order_note', label: 'Σημειώσεις Παραγγελίας', sub: 'Η γενική σημείωση της παραγγελίας' },
],
},
]
// ── Main tab ───────────────────────────────────────────────────────────────
export default function PrintFontsTab() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const { data: printers = [] } = useQuery({
queryKey: ['printers-all'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 15_000,
})
const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function val(key) { return settings?.[key]?.value ?? FONT_DEFAULTS[key] }
function handleChange(key, value) { updateMut.mutate({ key, value }) }
if (isLoading) {
return <div style={{ padding: 40, textAlign: 'center', color: '#9ca3af', fontSize: 14 }}>Φόρτωση</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 1. Printers */}
<PrintersSection />
{/* 2. Ticket mode */}
<TicketModeSection
value={val('print.ticket_mode')}
onChange={handleChange}
isPending={updateMut.isPending}
printers={printers}
/>
{/* 3. Font sizes — grouped */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
<p className="text-xs text-gray-400 mt-0.5">
Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.
</p>
</div>
{FONT_GROUPS.map(group => (
<div key={group.group}>
<SubgroupHeader label={group.group} />
{group.fields.map((field, idx) => (
<FontRow
key={field.key}
field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
nested={group.fields.length > 1}
/>
))}
</div>
))}
</div>
{/* 4. Divider style */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
</div>
<DividerRow
value={val('print.divider_style')}
onChange={handleChange}
isPending={updateMut.isPending}
/>
</div>
<div style={{
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10,
padding: '12px 16px', fontSize: 12, color: '#92400e', lineHeight: 1.6,
}}>
<strong>Σημείωση:</strong> Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48.
Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
</div>
</div>
)
}

View File

@@ -0,0 +1,270 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Shield, Lock, LogOut, User, KeyRound } from 'lucide-react'
import client from '../../../api/client'
import { PANEL_CLASS } from '../../../ui/tokens'
import Button from '../../../ui/Button'
const INACTIVITY_PRESETS = [
{ label: '5 δευτερόλεπτα', value: 5 },
{ label: '10 δευτερόλεπτα', value: 10 },
{ label: '30 δευτερόλεπτα', value: 30 },
{ label: '1 λεπτό', value: 60 },
{ label: '2 λεπτά', value: 120 },
{ label: '5 λεπτά', value: 300 },
{ label: '10 λεπτά', value: 600 },
{ label: '15 λεπτά', value: 900 },
{ label: '30 λεπτά', value: 1800 },
{ label: '1 ώρα', value: 3600 },
]
function fmtSeconds(s) {
const n = parseInt(s, 10)
if (isNaN(n) || n <= 0) return ''
if (n < 60) return `${n}s`
if (n < 3600) return `${Math.round(n / 60)}m`
return `${Math.round(n / 3600)}h`
}
// ─── Shared primitives ────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className={`${PANEL_CLASS} divide-y divide-gray-100`}>
<div className="flex items-start gap-3 px-5 py-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-50 flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-sky-500" />
</div>
<div>
<p className="font-semibold text-gray-700">{title}</p>
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
</div>
</div>
{children}
</div>
)
}
function OptionRow({ label, description, children }) {
return (
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800">{label}</p>
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
)
}
function Toggle({ checked, onChange, disabled }) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 flex-shrink-0 disabled:opacity-50 ${
checked ? 'bg-sky-500' : 'bg-slate-200'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-all duration-200 ${
checked ? 'left-6' : 'left-1'
}`} />
</button>
)
}
function SegmentedControl({ options, value, onChange, disabled }) {
return (
<div className="flex rounded-lg border border-gray-200 overflow-hidden bg-gray-50 p-0.5 gap-0.5">
{options.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => !disabled && onChange(opt.value)}
disabled={disabled}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
value === opt.value
? 'bg-white text-gray-800 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{opt.label}
</button>
))}
</div>
)
}
function TimeSelect({ value, onChange, disabled }) {
return (
<div className="flex items-center gap-2">
<select
value={value}
onChange={e => onChange(e.target.value)}
disabled={disabled}
className="h-8 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 focus:outline-none focus:border-sky-400 disabled:opacity-50"
>
{INACTIVITY_PRESETS.map(p => (
<option key={p.value} value={String(p.value)}>{p.label}</option>
))}
</select>
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function SecurityTab() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => {
toast.success('Αποθηκεύτηκε')
qc.invalidateQueries({ queryKey: ['pos-settings'] })
},
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function get(key, fallback) {
return settings?.[key]?.value ?? fallback
}
function save(key, value) {
updateMut.mutate({ key, value })
}
const busy = updateMut.isPending
if (isLoading) {
return (
<div className="flex items-center justify-center h-48 text-slate-400 text-[13px]">
Φόρτωση
</div>
)
}
const loginMethod = get('security.login_method', 'password')
const autofill = get('security.autofill_username', 'true') === 'true'
const autoLock = get('security.auto_lock', 'false') === 'true'
const autoLockSecs = get('security.auto_lock_seconds', '300')
const autoLogout = get('security.auto_logout', 'false') === 'true'
const autoLogoutSecs = get('security.auto_logout_seconds', '1800')
return (
<div className="space-y-5">
{/* ── Μέθοδος σύνδεσης ─────────────────────────────────────────────── */}
<SectionCard
icon={KeyRound}
title="Σύνδεση"
description="Τι διαπιστευτήρια απαιτούνται όταν η εφαρμογή ξεκινά ή μετά από πλήρη αποσύνδεση."
>
<OptionRow
label="Μέθοδος σύνδεσης"
description="Πώς πιστοποιούνται οι διαχειριστές κατά την πρώτη πρόσβαση."
>
<SegmentedControl
value={loginMethod}
onChange={v => save('security.login_method', v)}
disabled={busy}
options={[
{ value: 'password', label: 'Κωδικός' },
{ value: 'pin', label: 'PIN' },
{ value: 'none', label: 'Κανένα' },
]}
/>
</OptionRow>
{loginMethod === 'none' && (
<div className="mx-5 mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[12px] text-amber-700">
<strong>Προσοχή:</strong> με έναν μόνο λογαριασμό διαχειριστή, η εφαρμογή θα ανοίγει χωρίς πιστοποίηση.
Χρησιμοποιήστε αυτό μόνο σε φυσικά ασφαλή, ιδιωτική συσκευή.
</div>
)}
<OptionRow
label="Αυτόματη συμπλήρωση ονόματος χρήστη"
description="Όταν υπάρχει μόνο ένας διαχειριστής, παράλειψη του πεδίου ονόματος."
>
<Toggle
checked={autofill}
onChange={v => save('security.autofill_username', v ? 'true' : 'false')}
disabled={busy}
/>
</OptionRow>
</SectionCard>
{/* ── Αυτόματο κλείδωμα ────────────────────────────────────────────── */}
<SectionCard
icon={Lock}
title="Αυτόματο Κλείδωμα"
description="Κλειδώνει την οθόνη μετά από αδράνεια. Ξεκλειδώνεται μόνο με PIN — γρήγορο και ασφαλές."
>
<OptionRow label="Ενεργοποίηση αυτόματου κλειδώματος" description="Εμφάνιση οθόνης κλειδώματος PIN μετά από αδράνεια.">
<Toggle
checked={autoLock}
onChange={v => save('security.auto_lock', v ? 'true' : 'false')}
disabled={busy}
/>
</OptionRow>
{autoLock && (
<OptionRow
label="Κλείδωμα μετά από"
description={`Η οθόνη κλειδώνει μετά από ${fmtSeconds(autoLockSecs)} αδράνειας.`}
>
<TimeSelect
value={autoLockSecs}
onChange={v => save('security.auto_lock_seconds', v)}
disabled={busy}
/>
</OptionRow>
)}
</SectionCard>
{/* ── Αυτόματη αποσύνδεση ──────────────────────────────────────────── */}
<SectionCard
icon={LogOut}
title="Αυτόματη Αποσύνδεση"
description="Αποσυνδέεται πλήρως μετά από αδράνεια. Απαιτεί εκ νέου πλήρη εισαγωγή διαπιστευτηρίων."
>
<OptionRow label="Ενεργοποίηση αυτόματης αποσύνδεσης" description="Πλήρης αποσύνδεση μετά από αδράνεια.">
<Toggle
checked={autoLogout}
onChange={v => save('security.auto_logout', v ? 'true' : 'false')}
disabled={busy}
/>
</OptionRow>
{autoLogout && (
<OptionRow
label="Αποσύνδεση μετά από"
description={`Πλήρης αποσύνδεση μετά από ${fmtSeconds(autoLogoutSecs)} αδράνειας.`}
>
<TimeSelect
value={autoLogoutSecs}
onChange={v => save('security.auto_logout_seconds', v)}
disabled={busy}
/>
</OptionRow>
)}
{autoLock && autoLogout && parseInt(autoLogoutSecs, 10) <= parseInt(autoLockSecs, 10) && (
<div className="mx-5 mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[12px] text-amber-700">
Ο χρόνος αποσύνδεσης είναι μικρότερος ή ίσος με τον χρόνο κλειδώματος η οθόνη θα αποσυνδεθεί πριν κλειδώσει.
</div>
)}
</SectionCard>
</div>
)
}

View File

@@ -0,0 +1,512 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowRight, User, Mail, Lock, Building2, Coffee,
UtensilsCrossed, Beer, ShoppingBag, CheckCircle2, ChevronRight, Hash,
} from 'lucide-react'
import client from '../api/client'
import Button from '../ui/Button'
import { LabelledInput } from '../ui/Input'
// ─── Step indicator ───────────────────────────────────────────────────────────
function StepDots({ total, current }) {
return (
<div className="flex items-center gap-2">
{Array.from({ length: total }).map((_, i) => (
<div
key={i}
className={`rounded-full transition-all duration-300 ${
i < current
? 'w-2 h-2 bg-sky-400'
: i === current
? 'w-6 h-2 bg-sky-500'
: 'w-2 h-2 bg-slate-200'
}`}
/>
))}
</div>
)
}
// ─── Venue type selector (multi-select) ───────────────────────────────────────
const VENUE_TYPES = [
{ id: 'restaurant', label: 'Restaurant', Icon: UtensilsCrossed },
{ id: 'bar', label: 'Bar', Icon: Beer },
{ id: 'coffee_shop', label: 'Coffee Shop', Icon: Coffee },
{ id: 'fast_food', label: 'Fast Food', Icon: ShoppingBag },
]
function VenueTypeButton({ type, selected, onToggle }) {
const { label, Icon } = type
return (
<button
type="button"
onClick={() => onToggle(type.id)}
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-[13px] font-medium transition-all ${
selected
? 'border-sky-500 bg-sky-50 text-sky-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
}`}
>
{selected && (
<div className="absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full bg-sky-500">
<CheckCircle2 className="h-3 w-3 text-white" />
</div>
)}
<Icon className={`h-6 w-6 ${selected ? 'text-sky-500' : 'text-slate-400'}`} />
{label}
</button>
)
}
// ─── PIN pad ──────────────────────────────────────────────────────────────────
const PIN_DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
function PinPad({ pin, onChange }) {
function press(d) {
if (d === '⌫') { onChange(p => p.slice(0, -1)); return }
if (d === '') return
if (pin.length >= 6) return
onChange(p => p + d)
}
return (
<div className="space-y-3">
<div className="flex justify-center gap-3 py-1">
{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">
{PIN_DIGITS.map((d, i) => (
<button
key={i}
type="button"
onClick={() => press(d)}
disabled={d === ''}
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'
}`}
>
{d}
</button>
))}
</div>
</div>
)
}
// ─── Steps ───────────────────────────────────────────────────────────────────
function StepWelcome({ onNext }) {
return (
<div className="flex flex-col items-center text-center gap-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-sky-500 shadow-lg shadow-sky-200">
<UtensilsCrossed className="h-10 w-10 text-white" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-slate-900">Welcome to Xenia POS</h1>
<p className="text-slate-500 text-[14px] leading-relaxed max-w-xs">
The complete point-of-sale system for your venue. Let's get you set up
in just a few steps — it won't take long.
</p>
</div>
<div className="w-full rounded-xl border border-slate-100 bg-slate-50 p-4 text-left space-y-2">
{[
'Create your manager account',
'Tell us about your venue',
'You\'re ready to go',
].map((s, i) => (
<div key={i} className="flex items-center gap-3 text-[13px] text-slate-600">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-sky-100 text-sky-600 text-[11px] font-semibold flex-shrink-0">
{i + 1}
</div>
{s}
</div>
))}
</div>
<Button variant="primary" className="w-full py-3 justify-center" onClick={onNext}>
Get Started <ArrowRight className="h-4 w-4" />
</Button>
<p className="text-[11px] text-slate-400">Thank you for choosing Xenia POS.</p>
</div>
)
}
function StepAccount({ data, onChange, onNext, onBack }) {
const [errors, setErrors] = useState({})
// Live password match: debounce 1s after confirm changes
const confirmTimer = useRef(null)
function handleConfirmChange(value) {
onChange('confirm', value)
clearTimeout(confirmTimer.current)
confirmTimer.current = setTimeout(() => {
if (value && data.password && value !== data.password) {
setErrors(e => ({ ...e, confirm: 'Passwords do not match' }))
} else {
setErrors(e => { const next = { ...e }; delete next.confirm; return next })
}
}, 1000)
}
// Also clear the error immediately if they now match
function handlePasswordChange(value) {
onChange('password', value)
if (data.confirm && value === data.confirm) {
setErrors(e => { const next = { ...e }; delete next.confirm; return next })
}
}
function validate() {
const e = {}
if (!data.username.trim()) e.username = 'Username is required'
if (!data.fullName.trim()) e.fullName = 'Full name is required'
if (!data.email.trim()) e.email = 'Email is required'
else if (!/\S+@\S+\.\S+/.test(data.email)) e.email = 'Enter a valid email'
if (data.password.length < 6) e.password = 'At least 6 characters'
if (data.password !== data.confirm) e.confirm = 'Passwords do not match'
setErrors(e)
return Object.keys(e).length === 0
}
return (
<div className="flex flex-col gap-5">
<div className="space-y-1">
<h2 className="text-xl font-bold text-slate-900">Create your manager account</h2>
<p className="text-[13px] text-slate-500">This will be the primary administrator account.</p>
</div>
<div className="space-y-3">
<div>
<LabelledInput
icon={User}
placeholder="Username"
value={data.username}
onChange={e => onChange('username', e.target.value)}
autoComplete="off"
autoFocus
/>
{errors.username && <p className="mt-1 text-[12px] text-rose-500">{errors.username}</p>}
</div>
<div>
<LabelledInput
icon={User}
placeholder="Full Name"
value={data.fullName}
onChange={e => onChange('fullName', e.target.value)}
autoComplete="off"
/>
{errors.fullName && <p className="mt-1 text-[12px] text-rose-500">{errors.fullName}</p>}
</div>
<div>
<LabelledInput
icon={Mail}
placeholder="Email address"
value={data.email}
onChange={e => onChange('email', e.target.value)}
type="email"
autoComplete="off"
/>
{errors.email && <p className="mt-1 text-[12px] text-rose-500">{errors.email}</p>}
</div>
<div>
<LabelledInput
icon={Lock}
placeholder="Password"
value={data.password}
onChange={e => handlePasswordChange(e.target.value)}
type="password"
autoComplete="new-password"
/>
{errors.password && <p className="mt-1 text-[12px] text-rose-500">{errors.password}</p>}
</div>
<div>
<LabelledInput
icon={Lock}
placeholder="Confirm Password"
value={data.confirm}
onChange={e => handleConfirmChange(e.target.value)}
type="password"
autoComplete="new-password"
/>
{errors.confirm && <p className="mt-1 text-[12px] text-rose-500">{errors.confirm}</p>}
</div>
</div>
<div className="flex gap-3">
<Button variant="ghost" className="flex-1 justify-center py-3" onClick={onBack}>
Back
</Button>
<Button variant="primary" className="flex-1 justify-center py-3" onClick={() => { if (validate()) onNext() }}>
Continue <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function StepVenue({ data, onChange, onNext, onBack }) {
const [errors, setErrors] = useState({})
function toggleType(id) {
const current = data.venueTypes
const next = current.includes(id)
? current.filter(v => v !== id)
: [...current, id]
onChange('venueTypes', next)
}
function validate() {
const e = {}
if (data.venueTypes.length === 0) e.venueType = 'Please select at least one type'
if (!data.venueName.trim()) e.venueName = 'Venue name is required'
setErrors(e)
return Object.keys(e).length === 0
}
return (
<div className="flex flex-col gap-5">
<div className="space-y-1">
<h2 className="text-xl font-bold text-slate-900">Tell us about your venue</h2>
<p className="text-[13px] text-slate-500">Select all that apply you can always change this later.</p>
</div>
<div className="space-y-4">
<div>
<p className="text-[12px] font-medium text-slate-500 uppercase tracking-wider mb-2">Venue type</p>
<div className="grid grid-cols-2 gap-2">
{VENUE_TYPES.map(type => (
<VenueTypeButton
key={type.id}
type={type}
selected={data.venueTypes.includes(type.id)}
onToggle={toggleType}
/>
))}
</div>
{errors.venueType && <p className="mt-1 text-[12px] text-rose-500">{errors.venueType}</p>}
</div>
<div>
<p className="text-[12px] font-medium text-slate-500 uppercase tracking-wider mb-2">Venue name</p>
<LabelledInput
icon={Building2}
placeholder="e.g. The Golden Fork"
value={data.venueName}
onChange={e => onChange('venueName', e.target.value)}
autoComplete="off"
/>
{errors.venueName && <p className="mt-1 text-[12px] text-rose-500">{errors.venueName}</p>}
</div>
</div>
<div className="flex gap-3">
<Button variant="ghost" className="flex-1 justify-center py-3" onClick={onBack}>
Back
</Button>
<Button variant="primary" className="flex-1 justify-center py-3" onClick={() => { if (validate()) onNext() }}>
Continue <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function StepPin({ pin, onChange, onNext, onBack }) {
const hasPin = pin.length >= 4
return (
<div className="flex flex-col gap-5">
<div className="space-y-1">
<h2 className="text-xl font-bold text-slate-900">Set a quick-access PIN</h2>
<p className="text-[13px] text-slate-500">
Optional. Used to unlock the dashboard quickly when it's locked,
without typing your full password.
</p>
</div>
<div className="rounded-xl border border-slate-100 bg-slate-50 p-5">
<PinPad pin={pin} onChange={onChange} />
</div>
{hasPin && (
<p className="text-center text-[12px] text-emerald-600 font-medium">
PIN set — {pin.length} digits
</p>
)}
<div className="flex gap-3">
<Button variant="ghost" className="flex-1 justify-center py-3" onClick={onBack}>
Back
</Button>
<Button
variant={hasPin ? 'primary' : 'secondary'}
className="flex-1 justify-center py-3"
onClick={onNext}
>
{hasPin ? <>Continue <ChevronRight className="h-4 w-4" /></> : 'Skip for now'}
</Button>
</div>
</div>
)
}
function StepDone({ onGoToLogin, loading, error }) {
return (
<div className="flex flex-col items-center text-center gap-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-200">
<CheckCircle2 className="h-10 w-10 text-white" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-slate-900">You're all set!</h2>
<p className="text-[14px] text-slate-500 leading-relaxed max-w-xs">
Your manager account is ready. Log in and start configuring your
products, tables, and staff.
</p>
</div>
<div className="w-full rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-left space-y-2">
{['Add your menu and products', 'Set up tables and zones', 'Add your waiters'].map((tip, i) => (
<div key={i} className="flex items-center gap-3 text-[13px] text-slate-600">
<ChevronRight className="h-4 w-4 text-emerald-500 flex-shrink-0" />
{tip}
</div>
))}
</div>
{error && <p className="text-[13px] text-rose-500">{error}</p>}
<Button
variant="primary"
className="w-full py-3 justify-center"
onClick={onGoToLogin}
disabled={loading}
>
{loading ? 'Setting up…' : 'Go to Login'}
{!loading && <ArrowRight className="h-4 w-4" />}
</Button>
</div>
)
}
// ─── Main wizard ──────────────────────────────────────────────────────────────
// Steps: 0=welcome, 1=account, 2=venue, 3=pin, 4=done
const INNER_STEPS = 3 // steps 1-3 show the dot indicator
export default function SetupWizard() {
const navigate = useNavigate()
const [step, setStep] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [account, setAccount] = useState({
username: '', fullName: '', email: '', password: '', confirm: '',
})
const [venue, setVenue] = useState({ venueTypes: [], venueName: '' })
const [pin, setPin] = useState('')
function updateAccount(key, value) {
setAccount(prev => ({ ...prev, [key]: value }))
}
function updateVenue(key, value) {
setVenue(prev => ({ ...prev, [key]: value }))
}
async function handleFinish() {
setLoading(true)
setError('')
try {
await client.post('/api/setup/init', {
username: account.username.trim(),
password: account.password,
email: account.email.trim(),
full_name: account.fullName.trim(),
venue_type: venue.venueTypes.join(','),
venue_name: venue.venueName.trim(),
pin: pin.length >= 4 ? pin : undefined,
})
navigate('/login', { replace: true })
} catch (err) {
setError(err.response?.data?.detail || 'Something went wrong. Please try again.')
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8">
{/* Dot indicator for inner steps only */}
{step >= 1 && step <= INNER_STEPS && (
<div className="flex justify-center mb-6">
<StepDots total={INNER_STEPS} current={step - 1} />
</div>
)}
{step === 0 && <StepWelcome onNext={() => setStep(1)} />}
{step === 1 && (
<StepAccount
data={account}
onChange={updateAccount}
onNext={() => setStep(2)}
onBack={() => setStep(0)}
/>
)}
{step === 2 && (
<StepVenue
data={venue}
onChange={updateVenue}
onNext={() => setStep(3)}
onBack={() => setStep(1)}
/>
)}
{step === 3 && (
<StepPin
pin={pin}
onChange={setPin}
onNext={() => setStep(4)}
onBack={() => setStep(2)}
/>
)}
{step === 4 && (
<StepDone
onGoToLogin={handleFinish}
loading={loading}
error={error}
/>
)}
</div>
<p className="text-center text-[11px] text-slate-400 mt-4">
Xenia POS · First-time Setup
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,497 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import { ConfirmModal } from '../ui/Modal'
import Button from '../ui/Button'
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return palette[h % palette.length]
}
function WaiterAvatar({ waiter, size = 40 }) {
const displayName = waiter.full_name || waiter.nickname || waiter.username
if (waiter.avatar_url) {
return (
<img
src={waiter.avatar_url}
alt={displayName}
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
/>
)
}
const parts = displayName.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(displayName),
color: 'white', fontSize: size * 0.38, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>{initials}</div>
)
}
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
function PinInput({ value, onChange }) {
function press(d) {
if (d === '⌫') { onChange(value.slice(0, -1)); return }
if (d === '' || value.length >= 6) return
onChange(value + d)
}
return (
<div className="space-y-3">
<div className="flex justify-center gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border-2 ${i < value.length ? 'bg-primary-700 border-primary-700' : 'border-gray-300'}`} />
))}
</div>
<div className="grid grid-cols-3 gap-2">
{DIGITS.map((d, i) => (
<button key={i} type="button" onClick={() => press(d)} disabled={d === ''} className={`h-11 rounded-xl text-lg font-semibold transition-colors ${d === '' ? 'invisible' : d === '⌫' ? 'bg-gray-100 hover:bg-gray-200 text-gray-600' : 'bg-gray-100 hover:bg-primary-100 text-gray-800'}`}>
{d}
</button>
))}
</div>
</div>
)
}
function ZoneModal({ waiter, groups, onClose }) {
const qc = useQueryClient()
// Derive initial state from waiter's zone_assignments
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
const [allZones, setAllZones] = useState(hasAllZones)
const [selected, setSelected] = useState(new Set(assignedIds))
const saveZones = useMutation({
mutationFn: (body) => client.put(`/api/waiters/${waiter.id}/zones`, body),
onSuccess: () => { toast.success('Zones ενημερώθηκαν'); qc.invalidateQueries({ queryKey: ['waiters'] }); onClose() },
onError: () => toast.error('Σφάλμα'),
})
function toggleGroup(gid) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(gid)) next.delete(gid); else next.add(gid)
return next
})
}
function save() {
if (allZones) {
saveZones.mutate({ all_zones: true, group_ids: [] })
} else {
saveZones.mutate({ all_zones: false, group_ids: [...selected] })
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Ζώνες {waiter.username}</h2>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={allZones}
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }}
/>
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
</label>
{!allZones && (
<div className="space-y-2 max-h-60 overflow-y-auto">
{groups.length === 0 && (
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
)}
{groups.map(g => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={selected.has(g.id)}
onChange={() => toggleGroup(g.id)}
/>
<span className="text-gray-700">{g.name}</span>
{g.color && (
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
)}
</label>
))}
</div>
)}
{!allZones && selected.size === 0 && (
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
</p>
)}
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
Αποθήκευση
</button>
</div>
</div>
</div>
)
}
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter', pin: '' }
export default function WaitersPage() {
const qc = useQueryClient()
const [addModal, setAddModal] = useState(false)
const [pinModal, setPinModal] = useState(null) // waiter id
const [zoneModal, setZoneModal] = useState(null) // waiter object
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
const [newPin, setNewPin] = useState('')
const [newForm, setNewForm] = useState(EMPTY_FORM)
const [newAvatarFile, setNewAvatarFile] = useState(null)
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
const [editModal, setEditModal] = useState(null) // waiter object
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter' })
const avatarInputRef = useRef(null)
const newAvatarInputRef = useRef(null)
const { data: waiters = [], isLoading } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
})
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
})
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
const createWaiter = useMutation({
mutationFn: async (body) => {
const res = await client.post('/api/waiters/', body)
if (newAvatarFile) {
const fd = new FormData()
fd.append('file', newAvatarFile)
await client.post(`/api/waiters/${res.data.id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
}
return res
},
onSuccess: () => {
toast.success('Σερβιτόρος δημιουργήθηκε')
setAddModal(false)
setNewForm(EMPTY_FORM)
setNewAvatarFile(null)
setNewAvatarPreview(null)
invalidate()
},
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const updateWaiter = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/waiters/${id}`, body),
onSuccess: () => { toast.success('Στοιχεία ενημερώθηκαν'); setEditModal(null); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const uploadAvatar = useMutation({
mutationFn: ({ id, file }) => {
const fd = new FormData()
fd.append('file', file)
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
},
onSuccess: (res) => {
toast.success('Avatar ανέβηκε')
setEditModal(res.data)
invalidate()
},
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
})
const deleteAvatar = useMutation({
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
onSuccess: (res) => {
toast.success('Avatar αφαιρέθηκε')
setEditModal(res.data)
invalidate()
},
onError: () => toast.error('Σφάλμα'),
})
const toggleBlock = useMutation({
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
onSuccess: () => { invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const resetPin = useMutation({
mutationFn: ({ id, pin }) => client.put(`/api/waiters/${id}/reset-pin?pin=${encodeURIComponent(pin)}`),
onSuccess: () => { toast.success('PIN ανανεώθηκε'); setPinModal(null); setNewPin('') },
onError: () => toast.error('Σφάλμα'),
})
const deleteWaiter = useMutation({
mutationFn: (id) => client.delete(`/api/waiters/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); setConfirmDelete(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return (
<div className="flex flex-col h-full min-h-0">
{/* Toolbar */}
<div className="flex items-center justify-end gap-2 border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέος σερβιτόρος</Button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<div className="card divide-y divide-gray-100">
{waiters.length === 0 && (
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν σερβιτόροι.</p>
)}
{waiters.map(w => (
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
<WaiterAvatar waiter={w} size={44} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
</div>
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
{w.role === 'waiter' && (
<p className="text-xs text-gray-400 mt-0.5">
{w.zone_assignments.length === 0
? 'Χωρίς ζώνες'
: w.zone_assignments.some(z => z.group_id === null)
? 'Όλες οι ζώνες'
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
</p>
)}
</div>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
</span>
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', role: w.role || 'waiter' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{w.role === 'waiter' && (
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
)}
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
</button>
<button onClick={() => setConfirmDelete(w.id)} className="btn btn-ghost text-sm px-2 py-1.5 min-h-0 h-9 text-red-500 hover:bg-red-50">🗑</button>
</div>
))}
</div>
{/* Add waiter modal */}
{addModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
{/* Avatar picker */}
<div className="flex items-center gap-4">
{newAvatarPreview ? (
<img src={newAvatarPreview} alt="preview" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 28, color: '#9ca3af' }}>👤</span>
</div>
)}
<div className="flex flex-col gap-2">
<input
ref={newAvatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) {
setNewAvatarFile(file)
setNewAvatarPreview(URL.createObjectURL(file))
}
e.target.value = ''
}}
/>
<button onClick={() => newAvatarInputRef.current?.click()} type="button" className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8">
{newAvatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
</button>
{newAvatarPreview && (
<button type="button" onClick={() => { setNewAvatarFile(null); setNewAvatarPreview(null) }} className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50">
Αφαίρεση
</button>
)}
</div>
</div>
<div>
<label className="label">Πλήρες όνομα *</label>
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Παρατσούκλι (nickname) *</label>
<input className="input" placeholder="π.χ. Γιώργος" value={newForm.nickname} onChange={e => setNewForm(f => ({ ...f, nickname: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div>
<label className="label">Όνομα χρήστη *</label>
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
</div>
<div>
<label className="label">Ρόλος *</label>
<select className="input" value={newForm.role} onChange={e => setNewForm(f => ({ ...f, role: e.target.value }))}>
<option value="waiter">Σερβιτόρος</option>
<option value="manager">Διαχειριστής</option>
</select>
</div>
<div>
<label className="label mb-2">PIN *</label>
<PinInput value={newForm.pin} onChange={pin => setNewForm(f => ({ ...f, pin }))} />
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
disabled={createWaiter.isPending || !newForm.username.trim() || !newForm.full_name.trim() || !newForm.nickname.trim() || newForm.pin.length < 4}
className="flex-1 btn btn-primary"
>
{createWaiter.isPending ? 'Δημιουργία…' : 'Δημιουργία'}
</button>
</div>
</div>
</div>
)}
{/* Edit profile modal */}
{editModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
<h2 className="font-bold text-gray-800">Επεξεργασία {editModal.full_name || editModal.username}</h2>
{/* Avatar section */}
<div className="flex items-center gap-4">
<WaiterAvatar waiter={editModal} size={64} />
<div className="flex flex-col gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) uploadAvatar.mutate({ id: editModal.id, file })
e.target.value = ''
}}
/>
<button
onClick={() => avatarInputRef.current?.click()}
disabled={uploadAvatar.isPending}
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
>
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
</button>
{editModal.avatar_url && (
<button
onClick={() => deleteAvatar.mutate(editModal.id)}
disabled={deleteAvatar.isPending}
className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50"
>
Αφαίρεση
</button>
)}
</div>
</div>
<div>
<label className="label">Πλήρες όνομα *</label>
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Παρατσούκλι (nickname) *</label>
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div>
<label className="label">Όνομα χρήστη *</label>
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} />
</div>
<div>
<label className="label">Ρόλος *</label>
<select className="input" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}>
<option value="waiter">Σερβιτόρος</option>
<option value="manager">Διαχειριστής</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null, role: editForm.role })}
disabled={updateWaiter.isPending || !editForm.username.trim() || !editForm.full_name.trim() || !editForm.nickname.trim()}
className="flex-1 btn btn-primary"
>
{updateWaiter.isPending ? 'Αποθήκευση…' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)}
{/* Reset PIN modal */}
{pinModal !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
<h2 className="font-bold text-gray-800">Reset PIN {waiters.find(w => w.id === pinModal)?.username}</h2>
<PinInput value={newPin} onChange={setNewPin} />
<div className="flex gap-3 pt-2">
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })}
disabled={newPin.length < 4}
className="flex-1 btn btn-primary"
>
Αποθήκευση
</button>
</div>
</div>
</div>
)}
{confirmDelete !== null && (
<ConfirmModal
title="Διαγραφή σερβιτόρου;"
message="Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
confirmLabel="Διαγραφή"
confirmVariant="danger"
onConfirm={() => deleteWaiter.mutate(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
{zoneModal && (
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,495 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import { ConfirmModal } from '../ui/Modal'
import Button from '../ui/Button'
const MAX_TABLE_NAME_LENGTH = 6
const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
function ZoneColorPicker({ value, onChange }) {
return (
<div className="flex flex-wrap gap-2 mt-1">
<button
type="button"
onClick={() => onChange(null)}
className="w-7 h-7 rounded-full border-2 bg-gray-200 transition-all"
style={{ borderColor: !value ? '#000' : 'transparent' }}
title="Χωρίς χρώμα"
/>
{ZONE_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => onChange(c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
/>
))}
</div>
)
}
export default function TablesPage() {
const qc = useQueryClient()
const [addModal, setAddModal] = useState(false)
const [editModal, setEditModal] = useState(null)
const [batchModal, setBatchModal] = useState(null) // group object or null
const [groupModal, setGroupModal] = useState(null) // null | {} | group object
const [confirmDelete, setConfirmDelete] = useState(null)
const [showInactive, setShowInactive] = useState(false)
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
const [selected, setSelected] = useState(new Set())
const [anyHovered, setAnyHovered] = useState(false)
const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables-all', showInactive],
queryFn: () => client.get(`/api/tables/?include_inactive=${showInactive}`).then(r => r.data),
})
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['tables-all'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
const createTable = useMutation({
mutationFn: (body) => client.post('/api/tables/', body),
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const batchCreate = useMutation({
mutationFn: (body) => client.post('/api/tables/batch', body),
onSuccess: (res) => { toast.success(`${res.data.length} τραπέζια δημιουργήθηκαν`); setBatchModal(null); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const updateTable = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/tables/${id}`, body),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); setEditModal(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const deleteTable = useMutation({
mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
onSuccess: (_, vars) => {
toast.success(vars.hard ? 'Διαγράφηκε' : 'Απενεργοποιήθηκε')
setConfirmDelete(null)
invalidate()
},
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const saveGroup = useMutation({
mutationFn: (body) => groupModal?.id
? client.put(`/api/tables/groups/${groupModal.id}`, body)
: client.post('/api/tables/groups', body),
onSuccess: () => { toast.success('Ζώνη αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const deleteGroup = useMutation({
mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
onSuccess: () => { toast.success('Ζώνη διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
// Filter tables for the active tab
const visibleTables = activeTab === 'all'
? tables
: activeTab === 'ungrouped'
? tables.filter(t => !t.group_id)
: tables.filter(t => t.group_id === activeTab)
const toggleSelect = (id) => setSelected(prev => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n
})
const clearSelect = () => setSelected(new Set())
const anySelected = selected.size > 0
function bulkDelete() {
setConfirmDelete({ type: 'bulk', ids: [...selected] })
}
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
const zoneTabs = [
{ id: 'all', label: 'Όλα', color: null },
...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} ${g.name}` : g.name, color: g.color, group: g })),
...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
]
return (
<div className="flex flex-col h-full min-h-0">
{/* Toolbar */}
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
{anySelected ? (
<>
<button onClick={clearSelect} className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1">
<span className="w-4 h-4 rounded border border-slate-300 flex items-center justify-center text-[10px]"></span>
{selected.size} επιλεγμένα
</button>
<Button variant="danger" size="sm" onClick={bulkDelete}>Διαγραφή επιλεγμένων</Button>
<span className="flex-1" />
</>
) : (
<span className="flex-1" />
)}
<Button
variant={showInactive ? 'primary' : 'secondary'}
size="sm"
onClick={() => setShowInactive(v => !v)}
>
{showInactive ? '✓ ' : ''}Ανενεργά
</Button>
<Button variant="secondary" size="sm" onClick={() => setGroupModal({})}>+ Νέα ζώνη</Button>
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέο τραπέζι</Button>
</div>
{/* Zone tabs */}
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
{zoneTabs.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); clearSelect() }}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-sky-500 text-sky-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
{tab.label}
<span className="ml-0.5 text-xs text-slate-400">
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
</span>
</button>
))}
</div>
{/* Zone action bar (when viewing a specific zone) */}
{activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
const g = groups.find(g => g.id === activeTab)
if (!g) return null
return (
<div className="flex items-center gap-3 px-6 py-2 border-b border-slate-100 flex-shrink-0">
<span className="font-semibold text-slate-700 text-sm">{g.name}</span>
{g.prefix && <span className="text-xs bg-white text-slate-500 border border-slate-200 px-2 py-0.5 rounded font-mono">{g.prefix}</span>}
<button onClick={() => setGroupModal(g)} className="text-xs text-slate-400 hover:text-slate-600 underline ml-1">Επεξεργασία</button>
<Button variant="secondary" size="sm" onClick={() => setBatchModal(g)} className="ml-auto">+ Μαζική προσθήκη</Button>
</div>
)
})()}
{/* Tables list */}
<div className="flex-1 overflow-y-auto p-6">
{visibleTables.length === 0 ? (
<p className="py-10 text-sm text-slate-400 text-center">
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
</p>
) : (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden divide-y divide-slate-100">
{visibleTables.map((t, idx) => {
const isSelected = selected.has(t.id)
return (
<div
key={t.id}
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'}`}
onMouseEnter={() => setAnyHovered(true)}
onMouseLeave={() => setAnyHovered(false)}
>
{/* Number / Checkbox */}
<div className="w-6 shrink-0 flex items-center justify-center">
{anySelected || isSelected ? (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(t.id)}
className="w-4 h-4 rounded accent-sky-500 cursor-pointer"
onClick={e => e.stopPropagation()}
/>
) : (
<span
className="text-xs text-slate-400 font-mono group-hover:hidden"
>{idx + 1}</span>
)}
{!anySelected && !isSelected && (
<input
type="checkbox"
checked={false}
onChange={() => toggleSelect(t.id)}
className="hidden group-hover:block w-4 h-4 rounded accent-sky-500 cursor-pointer"
onClick={e => e.stopPropagation()}
/>
)}
</div>
<p className="flex-1 font-medium text-slate-800">{t.label || `Τραπέζι ${t.number}`}</p>
{t.group && (
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded hidden sm:inline">
{t.group.name}
</span>
)}
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
{t.is_active
? <Button
variant="ghost"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
>Απενεργ.</Button>
: <Button
variant="ghost"
size="sm"
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
className="text-green-600 hover:bg-green-50"
>Ενεργοπ.</Button>
}
<Button
variant="danger"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="disabled:opacity-40 disabled:cursor-not-allowed"
>Διαγραφή</Button>
</div>
)
})}
</div>
)}
</div>
{/* Add single table */}
{addModal && (
<TableModal
title="Νέο τραπέζι"
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
groups={groups}
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setAddModal(false)}
/>
)}
{/* Edit table */}
{editModal && (
<TableModal
title="Επεξεργασία τραπεζιού"
initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
groups={groups}
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setEditModal(null)}
/>
)}
{/* Batch add */}
{batchModal !== null && (
<BatchModal
group={batchModal}
tables={tables}
onSave={(body) => batchCreate.mutate(body)}
onClose={() => setBatchModal(null)}
/>
)}
{/* Group/Zone form */}
{groupModal !== null && (
<GroupModal
group={groupModal}
onSave={(data) => saveGroup.mutate(data)}
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
onClose={() => setGroupModal(null)}
/>
)}
{/* Delete confirmation */}
{confirmDelete && (
<ConfirmModal
title={
confirmDelete.type === 'bulk'
? `Διαγραφή ${confirmDelete.ids.length} τραπεζιών;`
: confirmDelete.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'
}
message={
confirmDelete.type === 'bulk'
? `${confirmDelete.ids.length} τραπέζια θα διαγραφούν οριστικά.`
: confirmDelete.hard
? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'
}
confirmLabel={confirmDelete.type === 'bulk' || confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
confirmVariant="danger"
onConfirm={async () => {
if (confirmDelete.type === 'bulk') {
await Promise.allSettled(confirmDelete.ids.map(id => client.delete(`/api/tables/${id}?hard=true`)))
toast.success(`${confirmDelete.ids.length} τραπέζια διαγράφηκαν`)
setConfirmDelete(null); clearSelect(); invalidate()
} else {
deleteTable.mutate({ id: confirmDelete.id, hard: confirmDelete.hard })
}
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
)
}
function TableModal({ title, initial, groups, onSave, onClose }) {
const [form, setForm] = useState(initial)
const labelLen = (form.label || '').length
const labelTooLong = labelLen > MAX_TABLE_NAME_LENGTH
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">{title}</h2>
<div>
<label className="label">Όνομα τραπεζιού</label>
<input
className="input"
placeholder="π.χ. BS-1 ή Β3"
value={form.label}
maxLength={MAX_TABLE_NAME_LENGTH}
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
autoFocus
/>
<div className="flex justify-between mt-1">
<p className="text-xs text-gray-400">Αφήστε κενό για αυτόματη αρίθμηση.</p>
{form.label ? (
<p className={`text-xs font-mono ${labelLen >= MAX_TABLE_NAME_LENGTH ? 'text-red-500' : 'text-gray-400'}`}>
{labelLen}/{MAX_TABLE_NAME_LENGTH}
</p>
) : null}
</div>
{labelTooLong && (
<p className="text-xs text-red-500">Το όνομα δεν μπορεί να υπερβαίνει τους {MAX_TABLE_NAME_LENGTH} χαρακτήρες.</p>
)}
</div>
<div>
<label className="label">Ζώνη</label>
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
<option value=""> Χωρίς ζώνη </option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave(form)}
disabled={labelTooLong}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Αποθήκευση
</button>
</div>
</div>
</div>
)
}
function computeStartNumber(tables, groupId, prefix) {
if (!prefix) return 1
const inGroup = groupId ? tables.filter(t => t.group_id === groupId) : []
const used = inGroup
.filter(t => t.label && t.label.startsWith(prefix))
.map(t => {
const suffix = t.label.slice(prefix.length)
return /^\d+$/.test(suffix) ? parseInt(suffix, 10) : null
})
.filter(n => n !== null)
return used.length > 0 ? Math.max(...used) + 1 : 1
}
function BatchModal({ group, tables, onSave, onClose }) {
const [count, setCount] = useState(5)
const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
const trimmedPrefix = prefix.trim()
const startNumber = computeStartNumber(tables, group?.id ?? null, trimmedPrefix)
const lastN = startNumber + count - 1
const worstCase = `${trimmedPrefix}${lastN}`
const lengthError = trimmedPrefix && worstCase.length > MAX_TABLE_NAME_LENGTH
? `O τελευταίος πίνακας θα ονομαστεί '${worstCase}' (${worstCase.length} χαρ.). Μικρύνετε το πρόθεμα ή μειώστε τον αριθμό.`
: null
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Μαζική προσθήκη τραπεζιών</h2>
{group && <p className="text-sm text-gray-500">Ζώνη: <span className="font-medium text-gray-700">{group.name}</span></p>}
<div>
<label className="label">Πρόθεμα ονόματος</label>
<input
className="input"
placeholder="π.χ. BS- → BS-1, BS-2…"
value={prefix}
onChange={e => setPrefix(e.target.value)}
autoFocus
/>
{lengthError ? (
<p className="text-xs text-red-500 mt-1">{lengthError}</p>
) : (
<p className="text-xs text-gray-400 mt-1">Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.</p>
)}
</div>
<div>
<label className="label">Πλήθος</label>
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} />
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave({ group_id: group?.id ?? null, count, name_prefix: trimmedPrefix })}
disabled={count < 1 || !trimmedPrefix || !!lengthError}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Δημιουργία {count > 0 && trimmedPrefix && !lengthError ? `(${trimmedPrefix}${startNumber}${trimmedPrefix}${lastN})` : ''}
</button>
</div>
</div>
</div>
)
}
function GroupModal({ group, onSave, onDelete, onClose }) {
const [name, setName] = useState(group.name || '')
const [prefix, setPrefix] = useState(group.prefix || '')
const [color, setColor] = useState(group.color || null)
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}</h2>
<div>
<label className="label">Όνομα ζώνης *</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
</div>
<div>
<label className="label">Πρόθεμα (για μαζική δημιουργία)</label>
<input className="input font-mono" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="π.χ. BS" />
<p className="text-xs text-gray-400 mt-1">Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.</p>
</div>
<div>
<label className="label">Χρώμα ζώνης</label>
<ZoneColorPicker value={color} onChange={setColor} />
</div>
<div className="flex gap-3">
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">Διαγραφή</button>}
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,812 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
function relativeTime(isoStr) {
if (!isoStr) return ''
const diffMs = Date.now() - new Date(isoStr).getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'μόλις τώρα'
if (diffMins < 60) return `πριν ${diffMins}λ`
const h = Math.floor(diffMins / 60)
if (h < 24) return `πριν ${h}ω`
return `πριν ${Math.floor(h / 24)}μ`
}
function FlagsDetailModal({ flags, tableName, onClose }) {
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} onClick={onClose}>
<div style={{
background: 'white', borderRadius: 16, width: '100%', maxWidth: 360,
padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.18)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>Σημαίες {tableName}</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#8a9099' }}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{flags.map(f => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 10,
background: (f.color || '#6b7280') + '11',
border: `1px solid ${(f.color || '#6b7280')}33`,
}}>
{f.emoji && <span style={{ fontSize: 20 }}>{f.emoji}</span>}
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: f.color || '#374151' }}>{f.name}</div>
{f.assigned_at && (
<div style={{ fontSize: 11, color: '#8a9099', marginTop: 1 }}>{relativeTime(f.assigned_at)}</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}
const STATUS_OPTIONS = [
{ value: 'open', label: 'Ανοιχτά' },
{ value: 'partially_paid', label: 'Μερική πληρωμή' },
{ value: 'paid', label: 'Πλήρως πληρωμένο' },
{ value: 'free', label: 'Ελεύθερα' },
]
const COLORS = {
open: {
label: 'Ανοιχτό',
tint: '#eef7f0', tintStrong: '#d7ecdc',
accent: '#2f9e5e', ink: '#1f7042',
},
partially_paid: {
label: 'Μερική πληρ.',
tint: '#f4eefb', tintStrong: '#e3d4f3',
accent: '#7a44c9', ink: '#57309a',
},
paid: {
label: 'Πλήρως πληρωμένο',
tint: '#eff6ff', tintStrong: '#dbeafe',
accent: '#2563eb', ink: '#1d4ed8',
},
free: {
label: 'Ελεύθερο',
tint: '#f4f4f2', tintStrong: '#dfe2e6',
accent: '#8a9099', ink: '#5a6169',
},
}
function formatEuro(n) {
return '€' + parseFloat(n).toFixed(2)
}
function formatDuration(openedAt) {
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
if (mins < 60) return `${mins}m`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}h` : `${h}h ${m}m`
}
function occupiedMinsFromDate(openedAt) {
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
}
function orderTotal(items = []) {
return items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
}
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return palette[h % palette.length]
}
function WaiterBubble({ waiter, size = 26 }) {
if (waiter.avatarUrl) {
return (
<img src={waiter.avatarUrl} alt={waiter.name}
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)' }}
/>
)
}
const parts = waiter.name.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(waiter.name), color: 'white',
fontSize: size * 0.42, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
)
}
// ─── Quick action modal ────────────────────────────────────────────────────────
function QuickActionModal({ table, order, flagDefs, currentFlags, waiters, templates, onClose, onDone }) {
const qc = useQueryClient()
const [selectedFlagIds, setSelectedFlagIds] = useState((currentFlags || []).map(f => f.id))
const [notifyMsg, setNotifyMsg] = useState('')
const [notifyAll, setNotifyAll] = useState(true)
const [notifyWaiters, setNotifyWaiters] = useState(false)
const [sending, setSending] = useState(false)
const setFlagsMut = useMutation({
mutationFn: () => client.put(`/api/flags/table/${table.id}`, { flag_ids: selectedFlagIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['flag-assignments'] }),
})
function toggleFlag(id) {
setSelectedFlagIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
async function save() {
setSending(true)
try {
await setFlagsMut.mutateAsync()
if (notifyWaiters && notifyMsg.trim() && order) {
const waiterIds = notifyAll ? [] : order.waiters.map(w => w.waiter_id)
await client.post('/api/messages/send', {
body: notifyMsg.trim(),
target_waiter_ids: waiterIds,
table_ids: [table.id],
})
toast.success('Σημαίες + ειδοποίηση εστάλη!')
} else {
toast.success('Σημαίες ενημερώθηκαν')
}
onDone()
onClose()
} catch {
toast.error('Σφάλμα')
} finally {
setSending(false)
}
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} onClick={onClose}>
<div style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 460,
padding: 24, boxShadow: '0 24px 64px rgba(0,0,0,0.22)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}> {table.label || `T${table.number}`}</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#8a9099' }}></button>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Σημαίες</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
{flagDefs.map(f => {
const sel = selectedFlagIds.includes(f.id)
return (
<button key={f.id} onClick={() => toggleFlag(f.id)} style={{
height: 30, padding: '0 12px', borderRadius: 999,
border: `1.5px solid ${sel ? f.color : '#dfe2e6'}`,
background: sel ? (f.color + '22') : 'white',
color: sel ? f.color : '#374151',
fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 5,
}}>
{f.emoji && <span>{f.emoji}</span>}
{f.name}
</button>
)
})}
</div>
{order && (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={notifyWaiters} onChange={e => setNotifyWaiters(e.target.checked)} />
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>Ειδοποίησε τους σερβιτόρους</span>
</label>
{notifyWaiters && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
{templates.slice(0, 4).map(t => (
<button key={t.id} onClick={() => setNotifyMsg(t.body)} style={{
height: 26, padding: '0 10px', borderRadius: 6,
border: `1px solid ${notifyMsg === t.body ? '#3758c9' : '#dfe2e6'}`,
background: notifyMsg === t.body ? '#eff3ff' : '#f9fafb',
color: notifyMsg === t.body ? '#3758c9' : '#374151',
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>{t.body}</button>
))}
</div>
<input
value={notifyMsg}
onChange={e => setNotifyMsg(e.target.value)}
placeholder="Ή γράψτε μήνυμα…"
style={{ width: '100%', height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
</div>
)}
</>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ height: 36, padding: '0 16px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
<button onClick={save} disabled={sending} style={{ height: 36, padding: '0 18px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
{sending ? 'Αποθήκευση…' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)
}
function FlagPills({ flags, displayMode = 'both', onOverflowClick }) {
if (flags.length === 0) return null
const MAX_VISIBLE = 3
const visible = flags.slice(0, MAX_VISIBLE)
const overflow = flags.length - MAX_VISIBLE
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden', flexWrap: 'nowrap', minWidth: 0 }}>
{visible.map(f => (
<span key={f.id} style={{
fontSize: 11, fontWeight: 600, borderRadius: 999, padding: '2px 7px',
background: (f.color || '#6b7280') + '22',
color: f.color || '#6b7280',
border: `1px solid ${f.color || '#6b7280'}55`,
display: 'inline-flex', alignItems: 'center', gap: 3,
whiteSpace: 'nowrap', flexShrink: 0,
}}>
{(displayMode === 'icon' || displayMode === 'both') && f.emoji && <span style={{ fontSize: 12 }}>{f.emoji}</span>}
{(displayMode === 'text' || displayMode === 'both') && <span>{f.name}</span>}
</span>
))}
{overflow > 0 && (
<button
onClick={e => { e.stopPropagation(); onOverflowClick?.() }}
title={`+${overflow} ακόμα σημαίες`}
style={{
fontSize: 11, fontWeight: 700, borderRadius: 999, padding: '2px 7px',
background: '#f0f4ff', color: '#3758c9', border: '1px solid #c2cff0',
whiteSpace: 'nowrap', flexShrink: 0, cursor: 'pointer',
}}
>+{overflow}</button>
)}
</div>
)
}
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', onClick, onQuickAction, onFlagsClick }) {
const s = COLORS[status] || COLORS.free
const [hover, setHover] = useState(false)
const [pressed, setPressed] = useState(false)
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
return (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false) }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative', width: '100%', minWidth: 330, height: 200,
padding: '16px 18px 16px 24px',
background: s.tint, border: '1px solid ' + s.tintStrong, borderRadius: 14,
boxShadow: pressed
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
: hover
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'left', font: 'inherit', color: 'inherit',
display: 'flex', flexDirection: 'column', outline: 'none', flexShrink: 0,
}}
>
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent, borderRadius: '14px 0 0 14px',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 34, fontWeight: 700, lineHeight: 1, letterSpacing: -0.5,
color: '#111315', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px', borderRadius: 999,
background: s.accent, color: 'white',
fontSize: 12, fontWeight: 600, letterSpacing: 0.2,
whiteSpace: 'nowrap', flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
<div style={{ marginTop: 6, minHeight: 22, display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{hasPendingPrint && (
<span style={{
fontSize: 11, fontWeight: 700, background: '#92400e', color: '#fcd34d',
borderRadius: 999, padding: '2px 8px', flexShrink: 0,
display: 'inline-flex', alignItems: 'center', gap: 4,
}}></span>
)}
<FlagPills
flags={flags}
displayMode={flagDisplayMode}
onOverflowClick={onFlagsClick}
/>
</div>
<div style={{ marginTop: 'auto', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
</div>
<div style={{
marginTop: 12, paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36, display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
) : waiters.length >= 3 ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<WaiterBubble waiter={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: '#2b2f33',
background: 'white', border: '1px solid #dfe2e6',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<WaiterBubble waiter={w} size={24} />
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
</div>
))
)}
{onQuickAction && (
<button
onClick={e => { e.stopPropagation(); onQuickAction() }}
title="Γρήγορες ενέργειες"
style={{
marginLeft: 'auto', width: 28, height: 28, borderRadius: 8, flexShrink: 0,
border: '1px solid ' + s.tintStrong, background: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, cursor: 'pointer', color: '#5a6169',
}}
></button>
)}
</div>
</button>
)
}
// ─── Multi-select dropdown ─────────────────────────────────────────────────────
function MultiSelectDropdown({ label, options, selected, onChange, allLabel = 'Όλα' }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
const allSelected = selected.length === 0
const displayLabel = allSelected
? allLabel
: selected.length === 1
? options.find(o => o.value === selected[0])?.label ?? selected[0]
: `${selected.length} επιλεγμένα`
function toggle(value) {
if (selected.includes(value)) {
onChange(selected.filter(v => v !== value))
} else {
onChange([...selected, value])
}
}
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
height: 36, padding: '0 14px',
borderRadius: 8, border: '1px solid #dfe2e6',
background: allSelected ? 'white' : '#f0f4ff',
color: allSelected ? '#374151' : '#3758c9',
fontSize: 13, fontWeight: allSelected ? 500 : 600,
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
whiteSpace: 'nowrap', fontFamily: 'inherit',
boxShadow: open ? '0 0 0 2px #c2cff0' : 'none',
transition: 'box-shadow 100ms',
}}
>
<span style={{ fontSize: 11, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, marginRight: 2 }}>{label}</span>
{displayLabel}
<span style={{ fontSize: 10, color: '#8a9099', marginLeft: 2 }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: 6, zIndex: 100,
background: 'white', border: '1px solid #dfe2e6', borderRadius: 12,
boxShadow: '0 8px 24px rgba(16,20,24,0.12)',
minWidth: 180, overflow: 'hidden',
}}>
{/* All option */}
<button
onClick={() => { onChange([]); setOpen(false) }}
style={{
width: '100%', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
background: allSelected ? '#f0f4ff' : 'white',
border: 'none', cursor: 'pointer', textAlign: 'left',
fontSize: 13, fontWeight: allSelected ? 700 : 400,
color: allSelected ? '#3758c9' : '#374151',
fontFamily: 'inherit',
borderBottom: '1px solid #f4f4f2',
}}
>
<span style={{
width: 16, height: 16, borderRadius: 4,
border: '1.5px solid ' + (allSelected ? '#3758c9' : '#dfe2e6'),
background: allSelected ? '#3758c9' : 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{allSelected && <span style={{ color: 'white', fontSize: 10, fontWeight: 700 }}></span>}
</span>
{allLabel}
</button>
{options.map(opt => {
const checked = selected.includes(opt.value)
return (
<button
key={opt.value}
onClick={() => toggle(opt.value)}
style={{
width: '100%', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
background: checked ? '#f9f5ff' : 'white',
border: 'none', cursor: 'pointer', textAlign: 'left',
fontSize: 13, fontWeight: checked ? 600 : 400,
color: checked ? '#57309a' : '#374151',
fontFamily: 'inherit',
borderBottom: '1px solid #f9f9f8',
}}
>
<span style={{
width: 16, height: 16, borderRadius: 4,
border: '1.5px solid ' + (checked ? '#7a44c9' : '#dfe2e6'),
background: checked ? '#7a44c9' : 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{checked && <span style={{ color: 'white', fontSize: 10, fontWeight: 700 }}></span>}
</span>
{opt.label}
</button>
)
})}
</div>
)}
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function TablesPage() {
const [statusFilter, setStatusFilter] = useState([])
const [zoneFilter, setZoneFilter] = useState([])
const [retryingId, setRetryingId] = useState(null)
const [quickActionTarget, setQuickActionTarget] = useState(null)
const [flagsDetail, setFlagsDetail] = useState(null) // { flags, tableName }
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: tables = [], isLoading: tablesLoading } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
refetchInterval: 5_000,
})
const { data: orders = [], isLoading: ordersLoading } = useQuery({
queryKey: ['orders-active'],
queryFn: () => client.get('/api/orders/').then(r => r.data),
refetchInterval: 5_000,
})
const { data: flagDefs = [] } = useQuery({
queryKey: ['flag-defs'],
queryFn: () => client.get('/api/flags/defs').then(r => r.data),
staleTime: 30_000,
})
const { data: flagAssignments = [] } = useQuery({
queryKey: ['flag-assignments'],
queryFn: () => client.get('/api/flags/assignments').then(r => r.data),
refetchInterval: 10_000,
staleTime: 8_000,
})
// Build map: tableId -> { def, assigned_at }[]
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
const tableFlagsMap = {}
flagAssignments.forEach(a => {
if (!tableFlagsMap[a.table_id]) tableFlagsMap[a.table_id] = []
const def = flagDefMap[a.flag_id]
if (def) tableFlagsMap[a.table_id].push({ ...def, assigned_at: a.assigned_at })
})
const { data: waiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const { data: quickTemplates = [] } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
staleTime: 60_000,
})
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
staleTime: 60_000,
})
const { data: posSettings } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const flagDisplayMode = posSettings?.['flags.display_mode']?.value ?? 'both'
const waiterMap = Object.fromEntries(waiters.map(w => {
const name = w.full_name || w.nickname || w.username
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
const avatarUrl = w.avatar_url ?? null
return [w.id, { name, shortName, avatarUrl }]
}))
const tableCards = tables.map(table => {
const order = orders.find(o =>
o.table_id === table.id && ['open', 'partially_paid', 'paid'].includes(o.status)
)
const tableStatus = order ? order.status : 'free'
const hasPendingPrint = order ? order.items.some(i => i.status === 'active' && !i.printed) : false
const tableFlags = tableFlagsMap[table.id] || []
return { table, order, tableStatus, hasPendingPrint, tableFlags }
})
const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
async function retrySingleOrder(orderId) {
setRetryingId(orderId)
try {
const res = await client.post(`/api/orders/${orderId}/retry-print`)
const results = res.data.print_results ?? []
const allOk = results.length === 0 || results.every(r => r.success)
if (allOk) toast.success('Εκτυπώθηκε επιτυχώς')
else {
const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
toast.error(`Αποτυχία: ${failed}`)
}
queryClient.invalidateQueries({ queryKey: ['orders-active'] })
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setRetryingId(null)
}
}
async function retryAllOrders() {
for (const { order } of pendingPrintOrders) {
if (order) await retrySingleOrder(order.id)
}
}
// Build zone options from groups + "Χωρίς ζώνη"
const zoneOptions = [
...groups.map(g => ({ value: String(g.id), label: g.name })),
{ value: '__none__', label: 'Χωρίς ζώνη' },
]
// Apply filters
const filtered = tableCards.filter(c => {
const statusOk = statusFilter.length === 0 || statusFilter.includes(c.tableStatus)
let zoneOk = true
if (zoneFilter.length > 0) {
const tableGroupId = c.table.group_id ? String(c.table.group_id) : null
if (zoneFilter.includes('__none__') && zoneFilter.length === 1) {
zoneOk = !tableGroupId
} else if (zoneFilter.includes('__none__')) {
zoneOk = !tableGroupId || zoneFilter.includes(tableGroupId)
} else {
zoneOk = tableGroupId !== null && zoneFilter.includes(tableGroupId)
}
}
return statusOk && zoneOk
})
if (tablesLoading || ordersLoading) {
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
}
return (
<div className="overflow-y-auto h-full p-6 space-y-6">
{flagsDetail && (
<FlagsDetailModal
flags={flagsDetail.flags}
tableName={flagsDetail.tableName}
onClose={() => setFlagsDetail(null)}
/>
)}
{quickActionTarget && (
<QuickActionModal
table={quickActionTarget.table}
order={quickActionTarget.order}
flagDefs={flagDefs}
currentFlags={quickActionTarget.currentFlags}
waiters={waiters}
templates={quickTemplates}
onClose={() => setQuickActionTarget(null)}
onDone={() => queryClient.invalidateQueries({ queryKey: ['flag-assignments'] })}
/>
)}
<div className="flex items-center justify-end flex-wrap gap-3">
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<MultiSelectDropdown
label="Κατάσταση"
options={STATUS_OPTIONS}
selected={statusFilter}
onChange={setStatusFilter}
allLabel="Όλες οι καταστάσεις"
/>
<MultiSelectDropdown
label="Ζώνη"
options={zoneOptions}
selected={zoneFilter}
onChange={setZoneFilter}
allLabel="Όλες οι ζώνες"
/>
</div>
</div>
{filtered.length === 0 && (
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
{filtered.map(({ table, order, tableStatus, hasPendingPrint, tableFlags }) => {
const waiterNames = order
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
: []
const amount = order ? orderTotal(order.items) : null
return (
<TableCardV1
key={table.id}
name={table.label || `T${table.number}`}
status={tableStatus}
amount={amount}
openedAt={order?.opened_at ?? null}
waiters={waiterNames}
hasPendingPrint={hasPendingPrint}
flags={tableFlags}
flagDisplayMode={flagDisplayMode}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}
/>
)
})}
</div>
{pendingPrintOrders.length > 0 && (
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
<div className="flex items-center gap-3">
<span style={{ fontSize: 20 }}></span>
<div>
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
<p className="text-xs text-orange-700 mt-0.5">
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
</p>
</div>
</div>
<button
className="btn btn-primary text-sm"
style={{ background: '#c2410c', borderColor: '#c2410c' }}
onClick={retryAllOrders}
disabled={retryingId !== null}
>
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
</button>
</div>
<div className="divide-y divide-orange-50">
{pendingPrintOrders.map(({ table, order }) => {
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
const tableName = table.label || `T${table.number}`
return (
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
{tableName}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-800">
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
</p>
<p className="text-xs text-gray-500 truncate">
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button className="btn btn-secondary text-xs" onClick={() => navigate(`/orders/${order.id}`)}>
Λεπτομέρειες
</button>
<button
className="btn btn-primary text-xs"
style={{ background: '#c2410c', borderColor: '#c2410c' }}
onClick={() => retrySingleOrder(order.id)}
disabled={retryingId === order.id}
>
{retryingId === order.id ? '…' : 'Εκτύπωση'}
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react'
import { Users, Utensils, Printer } from 'lucide-react'
import { TabGroup, TabBar } from '../../ui/Tabs'
// Staff Analytics
import ShiftsOverview from './staff/ShiftsOverview'
import Payments from './staff/Payments'
import Activity from './staff/Activity'
import StaffLeaderboard from './staff/StaffLeaderboard'
// Restaurant Performance
import Today from './restaurant/Today'
import WorkDaySummary from './restaurant/WorkDaySummary'
import OrderHistory from './restaurant/OrderHistory'
import ProductPerformance from './restaurant/ProductPerformance'
import CategoryPerformance from './restaurant/CategoryPerformance'
import TrafficAnalytics from './restaurant/TrafficAnalytics'
import RevenueTrends from './restaurant/RevenueTrends'
import TableAnalytics from './restaurant/TableAnalytics'
// Operations
import PrinterHistory from './operations/PrinterHistory'
import PrinterHealthLog from './operations/PrinterHealthLog'
import CancellationsLog from './operations/CancellationsLog'
const PARENT_TABS = [
{
id: 'staff',
label: 'Ανάλυση Προσωπικού',
icon: Users,
subTabs: [
{ id: 'shifts', label: 'Βάρδιες', Component: ShiftsOverview },
{ id: 'payments', label: 'Πληρωμές', Component: Payments },
{ id: 'activity', label: 'Δραστηριότητα', Component: Activity },
{ id: 'leaderboard', label: 'Κατάταξη', Component: StaffLeaderboard },
],
},
{
id: 'restaurant',
label: 'Απόδοση Καταστήματος',
icon: Utensils,
subTabs: [
{ id: 'today', label: 'Σήμερα', Component: Today },
{ id: 'workday', label: 'Ημέρες Λειτουργίας', Component: WorkDaySummary },
{ id: 'orders', label: 'Ιστορικό Παραγγελιών', Component: OrderHistory },
{ id: 'products', label: 'Απόδοση Προϊόντων', Component: ProductPerformance },
{ id: 'categories', label: 'Κατηγορίες', Component: CategoryPerformance },
{ id: 'traffic', label: 'Κίνηση', Component: TrafficAnalytics },
{ id: 'trends', label: 'Τάσεις Εσόδων', Component: RevenueTrends },
{ id: 'tables', label: 'Τραπέζια', Component: TableAnalytics },
],
},
{
id: 'ops',
label: 'Λειτουργίες',
icon: Printer,
subTabs: [
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
],
},
]
export default function ReportsPage() {
const [activeParent, setActiveParent] = useState('staff')
const [activeSubByParent, setActiveSubByParent] = useState({
staff: 'shifts',
restaurant: 'today',
ops: 'printer-history',
})
const parent = PARENT_TABS.find(t => t.id === activeParent)
const activeSubId = activeSubByParent[activeParent]
const sub = parent.subTabs.find(s => s.id === activeSubId) || parent.subTabs[0]
const { Component: SubComponent } = sub
function setActiveSub(id) {
setActiveSubByParent(prev => ({ ...prev, [activeParent]: id }))
}
return (
<div className="flex flex-col h-full min-h-0">
<TabGroup tabs={PARENT_TABS} active={activeParent} onChange={setActiveParent} />
<TabBar tabs={parent.subTabs} active={activeSubId} onChange={setActiveSub} />
<div className="flex-1 min-h-0 flex flex-col">
<SubComponent />
</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { XCircle, TrendingDown, Repeat, CheckCircle2 } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function CancellationsLog() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [waiterF, setWaiterF] = useState('all')
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
...(waiterF !== 'all' ? { waiter_id: waiterF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['cancellations', mode, from, to, businessDayId, waiterF],
queryFn: () => client.get('/api/reports/cancellations', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const waiterOptions = [{ value: 'all', label: 'Όλοι οι Σερβιτόροι' }, ...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name })))]
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const cancellations = data?.cancellations || []
const totalValue = data?.total_value || 0
const itemCounts = {}
cancellations.forEach(c => { itemCounts[c.product_name] = (itemCounts[c.product_name] || 0) + c.quantity })
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={8} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/cancellations/export" params={queryParams} filename={`cancellations-${from}-to-${to}.csv`} />
}>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
<FilterSelect value={waiterF} onChange={setWaiterF} options={waiterOptions} label="Σερβιτόρος" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-3 gap-4 mb-4">
<StatCard label="Συνολικές Ακυρώσεις" value={fmtNum(cancellations.length)} sub="ακυρωμένα είδη" icon={XCircle} />
<StatCard label="Συνολική Αξία Ακυρώσεων" value={fmtEUR(totalValue)} sub="χαμένα έσοδα" icon={TrendingDown} accent />
<StatCard label="Πιο Ακυρωμένο" value={topItem ? topItem[0] : '—'} sub={topItem ? `${topItem[1]} τεμ.` : ''} icon={Repeat} />
</div>
<Panel title="Ακυρώσεις" padded={false}>
{cancellations.length === 0 ? (
<EmptyState icon={CheckCircle2} title="Δεν υπάρχουν ακυρώσεις" description="Δεν ακυρώθηκε τίποτα σε αυτή την περίοδο." />
) : (
<>
<DataTable>
<THead>
<TH>Ώρα</TH><TH>Παραγγελία #</TH><TH>Τραπέζι</TH><TH>Σερβιτόρος</TH>
<TH>Είδος</TH><TH align="right">Αξία</TH><TH>Ακυρώθηκε από</TH><TH>Λόγος</TH>
</THead>
<tbody>
{cancellations.map(c => (
<TR key={c.id} striped>
<TD mono>{fmtDateTime(c.cancelled_at)}</TD>
<TD mono>#{c.order_id}</TD>
<TD>{c.table}</TD>
<TD><WaiterAvatar name={c.waiter_name} id={c.waiter_id} /></TD>
<TD>{c.product_name} <span className="text-slate-400">×{c.quantity}</span></TD>
<TD mono align="right" className="font-semibold text-rose-700">{fmtEUR(c.value)}</TD>
<TD className="text-slate-600">{c.cancelled_by || <span className="text-slate-300"></span>}</TD>
<TD className="text-slate-500">{c.cancel_reason || <span className="text-slate-300"></span>}</TD>
</TR>
))}
<tr className="bg-rose-50/40">
<TD colSpan={5} className="py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-slate-500">Σύνολο Ακυρωθέντων</TD>
<TD mono align="right" className="py-3 text-base font-semibold text-rose-700">{fmtEUR(totalValue)}</TD>
<TD colSpan={2} />
</tr>
</tbody>
</DataTable>
</>
)}
</Panel>
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtNum, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function PrinterHealthLog() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [printerF, setPrinterF] = useState('all')
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
from: from + 'T00:00:00',
to: to + 'T23:59:59',
...(printerF !== 'all' ? { printer_id: printerF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['printer-health', from, to, printerF],
queryFn: () => client.get('/api/reports/printers/history', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const printerOptions = [{ value: 'all', label: 'Όλοι οι Εκτυπωτές' }, ...((printersData?.printers || []).map(p => ({ value: String(p.id), label: p.name })))]
const logs = data?.logs || []
const succeeded = logs.filter(j => j.success).length
const failed = logs.filter(j => !j.success).length
const rate = logs.length ? (succeeded / logs.length * 100) : 100
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={6} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/printers/export" params={queryParams} filename={`printer-health-${from}-to-${to}.csv`} />
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<FilterSelect value={printerF} onChange={setPrinterF} options={printerOptions} label="Εκτυπωτής" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)] mb-4">
<div className="grid grid-cols-3 divide-x divide-slate-200">
<div className="px-2">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">Επιτυχημένες</div>
<div className="mt-1 font-mono text-[28px] font-medium tabular-nums text-emerald-600">{fmtNum(succeeded)}</div>
</div>
<div className="px-6">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">Αποτυχημένες</div>
<div className="mt-1 font-mono text-[28px] font-medium tabular-nums text-rose-600">{fmtNum(failed)}</div>
</div>
<div className="px-6">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">Επιτυχία %</div>
<div className="mt-1 font-mono text-[28px] font-medium tabular-nums text-slate-900">{rate.toFixed(1)}%</div>
</div>
</div>
</div>
<Panel title="Αρχείο Εργασιών" subtitle="Αποτυχημένες σε κόκκινο φόντο" padded={false}>
{logs.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν εργασίες εκτύπωσης" description="Δοκιμάστε διαφορετικό εύρος ημερομηνιών ή εκτυπωτή." />
) : (
<>
<DataTable>
<THead>
<TH>Ώρα</TH><TH>Εκτυπωτής</TH><TH>Παραγγελία #</TH><TH>Αποτέλεσμα</TH><TH>Σφάλμα</TH>
</THead>
<tbody>
{logs.slice(0, 100).map(j => (
<TR key={j.id} striped className={!j.success ? 'bg-rose-50/40' : ''}>
<TD mono>{fmtDateTime(j.printed_at)}</TD>
<TD>{j.printer_name}</TD>
<TD mono>#{j.order_id}</TD>
<TD><StatusBadge status={j.success ? 'success' : 'failed'} /></TD>
<TD className="font-mono text-[11px] text-rose-700">
{j.error_message || <span className="text-slate-300"></span>}
</TD>
</TR>
))}
</tbody>
</DataTable>
{logs.length > 100 && (
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-2 text-center text-[11px] text-slate-500">
Εμφάνιση πρώτων 100 από {logs.length} εργασίες.
</div>
)}
</>
)}
</Panel>
</div>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Printer, AlertCircle, Trophy } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtNum, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function PrinterHistory() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [printerF, setPrinterF] = useState('all')
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
...(printerF !== 'all' ? { printer_id: printerF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['printer-history', mode, from, to, businessDayId, printerF],
queryFn: () => client.get('/api/reports/printers/history', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const printerOptions = [{ value: 'all', label: 'Όλοι οι Εκτυπωτές' }, ...((printersData?.printers || []).map(p => ({ value: String(p.id), label: p.name })))]
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const logs = data?.logs || []
const total = data?.total || 0
const failed = data?.failed || 0
// Find most printed item
const itemCounts = {}
logs.forEach(l => (l.items || []).forEach(i => { itemCounts[i.name] = (itemCounts[i.name] || 0) + i.quantity }))
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/printers/export" params={queryParams} filename={`printer-history-${from}-to-${to}.csv`} />
}>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
<FilterSelect value={printerF} onChange={setPrinterF} options={printerOptions} label="Εκτυπωτής" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-3 gap-4 mb-4">
<StatCard label="Συνολικές Εκτυπώσεις" value={fmtNum(total)} icon={Printer} />
<StatCard label="Αποτυχημένες" value={fmtNum(failed)} sub={failed > 0 ? 'ελέγξτε τον εκτυπωτή' : 'όλα καλά'} icon={AlertCircle} />
<StatCard label="Πιο Εκτυπωμένο" value={topItem ? topItem[0] : '—'} sub={topItem ? `${topItem[1]} τεμ.` : ''} icon={Trophy} accent />
</div>
<Panel title="Εργασίες Εκτύπωσης" padded={false}>
{logs.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν εργασίες εκτύπωσης" description="Δοκιμάστε διαφορετικό εύρος ημερομηνιών ή εκτυπωτή." />
) : (
<>
<DataTable>
<THead>
<TH>Ώρα Εκτύπωσης</TH><TH>Εκτυπωτής</TH><TH>Παραγγελία #</TH><TH>Τραπέζι</TH><TH>Είδη</TH><TH>Αποτέλεσμα</TH>
</THead>
<tbody>
{logs.slice(0, 100).map(j => (
<TR key={j.id} striped className={!j.success ? 'bg-rose-50/40' : ''}>
<TD mono>{fmtDateTime(j.printed_at)}</TD>
<TD>{j.printer_name}</TD>
<TD mono>#{j.order_id}</TD>
<TD>{j.table}</TD>
<TD className="text-[12px] text-slate-600">
{(j.items || []).slice(0, 3).map((i, idx) => (
<span key={idx}>{i.name} ×{i.quantity}{idx < Math.min((j.items || []).length, 3) - 1 ? ', ' : ''}</span>
))}
{(j.items || []).length > 3 && <span className="text-slate-400"> +{(j.items || []).length - 3} ακόμα</span>}
</TD>
<TD><StatusBadge status={j.success ? 'success' : 'failed'} /></TD>
</TR>
))}
</tbody>
</DataTable>
{logs.length > 100 && (
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-2 text-center text-[11px] text-slate-500">
Εμφάνιση πρώτων 100 από {logs.length} εργασίες.
</div>
)}
</>
)}
</Panel>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, CHART_PALETTE } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function CategoryPerformance() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['category-performance', from, to],
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const categories = data?.categories || []
const pieData = categories.map((c, i) => ({
name: c.category_name,
value: c.revenue,
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
{categories.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα κατηγοριών" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-5">
<Panel title="Κατανομή Εσόδων">
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={60} outerRadius={100} paddingAngle={2}>
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
</PieChart>
</ResponsiveContainer>
</div>
</Panel>
</div>
<div className="col-span-7 grid grid-cols-2 gap-4">
{categories.map((c, i) => (
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
<span className="text-[12px] font-semibold uppercase tracking-wider text-slate-700">{c.category_name}</span>
</div>
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
<span className="font-mono">{c.pct}%</span>
</div>
</div>
))}
</div>
</div>
<div className="mt-4">
<Panel title="Κατηγορίες" padded={false}>
<DataTable>
<THead><TH>Κατηγορία</TH><TH align="right">Προϊόντα</TH><TH align="right">Τεμάχια</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">% Συνόλου</TH></THead>
<tbody>
{categories.map((c, i) => (
<TR key={c.category_id} striped>
<TD>
<span className="inline-flex items-center gap-2 font-medium text-slate-900">
<span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
{c.category_name}
</span>
</TD>
<TD mono align="right">{c.product_count}</TD>
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
<TD mono align="right">{c.pct}%</TD>
</TR>
))}
</tbody>
</DataTable>
</Panel>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import DrillDownModal from '../shared/DrillDownModal'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
const STATUS_OPTIONS = [
{ value: 'all', label: 'Όλες οι Καταστάσεις' },
{ value: 'open', label: 'Ανοιχτή' },
{ value: 'paid', label: 'Πληρωμένη' },
{ value: 'closed', label: 'Κλειστή' },
{ value: 'cancelled', label: 'Ακυρωμένη' },
]
export default function OrderHistory() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [statusF, setStatusF] = useState('all')
const [waiterF, setWaiterF] = useState('all')
const [tableF, setTableF] = useState('all')
const [drillOrder, setDrillOrder] = useState(null)
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: tablesData } = useQuery({ queryKey: ['meta-tables'], queryFn: () => client.get('/api/reports/meta/tables').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
...(statusF !== 'all' ? { status: statusF } : {}),
...(waiterF !== 'all' ? { waiter_id: waiterF } : {}),
...(tableF !== 'all' ? { table_id: tableF } : {}),
page_size: 200,
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['order-history', mode, from, to, businessDayId, statusF, waiterF, tableF],
queryFn: () => client.get('/api/reports/orders/history', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const waiterOptions = [{ value: 'all', label: 'Όλοι οι Σερβιτόροι' }, ...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name })))]
const tableOptions = [{ value: 'all', label: 'Όλα τα Τραπέζια' }, ...((tablesData?.tables || []).map(t => ({ value: String(t.id), label: t.name })))]
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const orders = Array.isArray(data) ? data : []
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={8} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/orders/export" params={queryParams} filename={`orders-${from}-to-${to}.csv`} />
}>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
<FilterSelect value={statusF} onChange={setStatusF} options={STATUS_OPTIONS} label="Κατάσταση" />
<FilterSelect value={waiterF} onChange={setWaiterF} options={waiterOptions} label="Σερβιτόρος" />
<FilterSelect value={tableF} onChange={setTableF} options={tableOptions} label="Τραπέζι" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Παραγγελίες" subtitle={`${orders.length} αποτελέσματα`} padded={false}>
{orders.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν παραγγελίες" description="Δοκιμάστε διαφορετικό εύρος ημερομηνιών ή φίλτρο κατάστασης." />
) : (
<>
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH>
</THead>
<tbody>
{orders.slice(0, 200).map(o => {
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
const isCancelled = o.status === 'cancelled'
return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
<TD mono>#{o.id}</TD>
<TD>{o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD><StatusBadge status={o.status} /></TD>
<TD mono align="right">{(o.items || []).length}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD align="right">
<button
onClick={() => setDrillOrder(o)}
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
>
Λεπτομέρειες
</button>
</TD>
</TR>
)
})}
</tbody>
</DataTable>
{orders.length > 200 && (
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-2 text-center text-[11px] text-slate-500">
Εμφάνιση πρώτων 200 από {orders.length} παραγγελίες. Περιορίστε τα φίλτρα για λιγότερα αποτελέσματα.
</div>
)}
</>
)}
</Panel>
</div>
{drillOrder && (
<DrillDownModal
title={`Order #${drillOrder.id}`}
subtitle={`${fmtDateTime(drillOrder.opened_at)} · Τραπέζι ${drillOrder.table_id}`}
onClose={() => setDrillOrder(null)}
>
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-4 flex items-center gap-3">
<StatusBadge status={drillOrder.status} />
{drillOrder.notes && (
<span className="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-800 ring-1 ring-inset ring-amber-200">
Σημείωση: {drillOrder.notes}
</span>
)}
</div>
<DataTable>
<THead>
<TH>Προϊόν</TH><TH align="right">Ποσ.</TH><TH align="right">Τιμή</TH><TH align="right">Υποσύνολο</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{(drillOrder.items || []).map((item, i) => (
<TR key={i} striped>
<TD className="font-medium">{item.product?.name ?? `#${item.product_id}`}</TD>
<TD mono align="right">×{item.quantity}</TD>
<TD mono align="right">{fmtEUR(item.unit_price)}</TD>
<TD mono align="right" className="font-semibold">{fmtEUR(item.unit_price * item.quantity)}</TD>
<TD><StatusBadge status={item.status} /></TD>
</TR>
))}
<tr className="bg-slate-50">
<TD colSpan={3} className="py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-slate-500">Σύνολο</TD>
<TD mono align="right" className="py-3 text-base font-semibold text-slate-900">
{fmtEUR((drillOrder.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0))}
</TD>
<TD />
</tr>
</tbody>
</DataTable>
</div>
</DrillDownModal>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { AlertTriangle } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, CHART_PALETTE } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function ProductPerformance() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [catF, setCatF] = useState('all')
const [chartMode, setChartMode] = useState('revenue')
const queryParams = {
from: from + 'T00:00:00',
to: to + 'T23:59:59',
...(catF !== 'all' ? { category_id: catF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['product-performance', from, to, catF],
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const products = data?.products || []
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
const top10 = products.slice(0, 10).map((p, i) => ({
name: p.product_name,
value: chartMode === 'revenue' ? p.revenue : p.qty_sold,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}))
const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel
title="Top 10 Προϊόντα"
subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`}
right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
{label}
</button>
))}
</div>
}
>
{top10.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => chartMode === 'revenue' ? '€' + v : String(v)} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>
{top10.map((d, i) => <Cell key={i} fill={d.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
<div className="mt-4">
<Panel title="Όλα τα Προϊόντα" subtitle={`${products.length} προϊόντα με πωλήσεις`} padded={false}>
{products.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<DataTable>
<THead>
<TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH>
<TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH>
</THead>
<tbody>
{products.map(p => {
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
return (
<TR key={p.product_id} striped>
<TD className="font-medium text-slate-900">{p.product_name}</TD>
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
<TD mono align="right">{pct.toFixed(1)}%</TD>
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>
)
})}
</tbody>
</DataTable>
)}
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, fmtDate } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
const GRAN_OPTIONS = [['daily', 'Ημερήσια'], ['weekly', 'Εβδομαδιαία'], ['monthly', 'Μηνιαία']]
export default function RevenueTrends() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [gran, setGran] = useState('daily')
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59', granularity: gran }
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['revenue-trends', from, to, gran],
queryFn: () => client.get('/api/reports/revenue/trends', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const trends = data?.trends || []
const chartData = trends.map(d => ({
label: gran === 'monthly' ? d.date : fmtDate(d.date),
revenue: d.revenue,
rolling7: d.rolling7,
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={3} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[12px]">
{GRAN_OPTIONS.map(([g, label]) => (
<button key={g} onClick={() => setGran(g)} className={`rounded px-2.5 py-1 font-medium transition ${gran === g ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
{label}
</button>
))}
</div>
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${from}${to}`}>
{trends.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
) : (
<div style={{ height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2.5} dot={{ r: 3, fill: '#60a5fa' }} />
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
</LineChart>
</ResponsiveContainer>
</div>
)}
</Panel>
<div className="mt-4">
<Panel title="Αναλυτικά Στοιχεία" padded={false}>
<DataTable>
<THead>
<TH>Περίοδος</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
</THead>
<tbody>
{trends.map(d => (
<TR key={d.date} striped>
<TD className="font-medium">{gran === 'monthly' ? d.date : fmtDate(d.date)}</TD>
<TD mono align="right">{fmtNum(d.orders)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
</TR>
))}
</tbody>
</DataTable>
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtMinutes } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function TableAnalytics() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['table-analytics', from, to],
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const tables = data?.tables || []
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
{tables.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα τραπεζιών" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
) : (
<>
<Panel title="Απόδοση Τραπεζιών" padded={false}>
<DataTable>
<THead>
<TH>Τραπέζι</TH><TH align="right">Παραγγελίες</TH><TH align="right">Μέση Διάρκεια</TH>
<TH align="right">Μέσο / Επίσκεψη</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">Κύκλοι</TH>
</THead>
<tbody>
{tables.map(t => {
const avgRev = t.order_count ? t.revenue / t.order_count : 0
return (
<TR key={t.table_id} striped>
<TD className="font-medium text-slate-900">{t.table_name}</TD>
<TD mono align="right">{fmtNum(t.order_count)}</TD>
<TD mono align="right">{fmtMinutes(t.avg_duration_minutes)}</TD>
<TD mono align="right">{fmtEUR(avgRev)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(t.revenue)}</TD>
<TD mono align="right">{fmtNum(t.order_count)}</TD>
</TR>
)
})}
</tbody>
</DataTable>
</Panel>
<div className="mt-4">
<Panel title="Ένταση Εσόδων" subtitle="Χρώμα κελιού = έσοδα ως προς κορυφαίο τραπέζι">
<div className="grid grid-cols-6 gap-3 sm:grid-cols-8 md:grid-cols-10">
{tables.map(t => {
const intensity = t.revenue / maxRev
return (
<div
key={t.table_id}
className="aspect-square flex flex-col justify-between rounded-md p-2 ring-1 ring-inset ring-slate-200"
style={{ backgroundColor: `rgba(96, 165, 250, ${0.08 + intensity * 0.65})` }}
>
<div className="font-mono text-[11px] font-semibold text-slate-700">{t.table_name}</div>
<div className="font-mono text-[10px] tabular-nums text-slate-700">{fmtEUR(t.revenue)}</div>
</div>
)
})}
</div>
</Panel>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
export default function Today() {
const { data: bdData, isLoading: bdLoading } = useQuery({
queryKey: ['business-day-current'],
queryFn: () => client.get('/api/reports/business-days/current').then(r => r.data),
staleTime: 30 * 1000,
refetchInterval: 30 * 1000,
})
const bd = bdData?.business_day
const { data: ordersData, isLoading: ordersLoading } = useQuery({
queryKey: ['today-orders', bd?.id],
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: bd.id, page_size: 200 } }).then(r => r.data),
enabled: !!bd?.id,
staleTime: 30 * 1000,
refetchInterval: 30 * 1000,
})
const { data: trafficData } = useQuery({
queryKey: ['today-traffic', bd?.id],
queryFn: () => client.get('/api/reports/traffic', { params: { business_day_id: bd.id } }).then(r => r.data),
enabled: !!bd?.id,
staleTime: 30 * 1000,
})
if (bdLoading || ordersLoading) {
return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={8} showChart /></div>
}
if (!bd) {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<span className="font-mono text-[12px] uppercase tracking-wider text-slate-500">Live snapshot</span>
</FilterBar>
<div className="flex flex-1 items-center justify-center p-12">
<EmptyState
icon={Sunset}
title="Δεν υπάρχει ενεργή εργάσιμη μέρα"
description="Ανοίξτε μια εργάσιμη μέρα από τον Πίνακα Ελέγχου για να ξεκινήσετε."
/>
</div>
</div>
)
}
const orders = Array.isArray(ordersData) ? ordersData : []
const hourlyMap = {}
;(trafficData?.by_hour || []).forEach(h => { hourlyMap[h.hour] = h })
const hours = Array.from({ length: 14 }, (_, i) => 10 + i)
const hourlyChart = hours.map(h => ({
hour: `${String(h).padStart(2, '0')}:00`,
revenue: hourlyMap[h]?.revenue || 0,
}))
const nowH = new Date().getHours()
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-2.5 py-1 text-[12px] font-medium text-emerald-700 ring-1 ring-inset ring-emerald-200">
<span className="relative h-1.5 w-1.5 rounded-full bg-emerald-500">
<span className="absolute inset-0 rounded-full bg-emerald-500 animate-ping opacity-75" />
</span>
Εργάσιμη μέρα ανοιχτή · άνοιξε {fmtTime(bd.opened_at)}
</div>
}>
<span className="font-mono text-[12px] uppercase tracking-wider text-slate-500">Ζωντανή Εικόνα</span>
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-12 gap-4">
<div className="col-span-5 rounded-lg border border-slate-200 bg-gradient-to-br from-sky-50 to-white p-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="flex items-center justify-between">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">Έσοδα Μέχρι Τώρα</div>
<TrendingUp className="h-4 w-4 text-sky-400" />
</div>
<div className="mt-2 font-mono text-[52px] font-medium tabular-nums leading-none tracking-tight text-slate-900">
{fmtEUR(bd.revenue)}
</div>
<div className="mt-3 flex items-center gap-4 text-[12px] text-slate-600">
<span><span className="font-mono font-semibold text-slate-900">{bd.orders_closed}</span> κλειστές παραγγελίες</span>
<span>·</span>
<span><span className="font-mono font-semibold text-slate-900">{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)}</span> μέσος όρος</span>
</div>
</div>
<div className="col-span-7 grid grid-cols-3 gap-4">
<StatCard label="Κλειστές Παραγγελίες" value={fmtNum(bd.orders_closed)} sub="πληρωμένες + κλειστές" icon={ReceiptText} />
<StatCard label="Ανοιχτές Παραγγελίες" value={fmtNum(bd.orders_open)} sub="αναμένουν πληρωμή" icon={Clock} accent />
<StatCard label="Ενεργοί Σερβιτόροι" value={fmtNum(bd.active_waiters)} icon={Users} />
{bd.top_product && (
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
)}
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
</div>
</div>
<div className="mt-4">
<Panel title="Έσοδα ανά Ώρα" subtitle="Μόνο κλειστές παραγγελίες" right={<span className="font-mono text-[11px] uppercase tracking-wider text-slate-500">Σήμερα</span>}>
<div style={{ height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hourlyChart} margin={{ top: 8, right: 8, bottom: 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="hour" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
<Tooltip content={<ChartTooltip formatter={(v, n) => fmtEUR(v)} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="revenue" radius={[3, 3, 0, 0]} maxBarSize={48}>
{hourlyChart.map((entry, i) => (
<Cell key={i} fill={parseInt(entry.hour) > nowH ? '#e2e8f0' : '#60a5fa'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</Panel>
</div>
<div className="mt-4">
<Panel title="Παραγγελίες Σήμερα" subtitle={`${orders.length} συνολικά`} padded={false} right={
<ExportButton
endpoint="/api/reports/orders/export"
params={{ business_day_id: bd.id }}
filename={`today-orders-${bd.id}.csv`}
/>
}>
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{orders.slice(0, 30).map(o => {
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
const isCancelled = o.status === 'cancelled'
return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
<TD mono>#{o.id}</TD>
<TD>{o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD mono align="right">{(o.items || []).length}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD><StatusBadge status={o.status} pulse /></TD>
</TR>
)
})}
</tbody>
</DataTable>
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { Panel, ChartTooltip } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtNum } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
const DOWS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυρ']
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
export default function TrafficAnalytics() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['traffic', from, to],
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const byHour = useMemo(() => {
const hourMap = {}
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
return HOURS.map(h => hourMap[h] || { hour: h, orders: 0, revenue: 0 })
}, [data])
const byWeekday = data?.by_weekday || []
// Build heatmap matrix [dow][hourIndex] = orders
const matrix = useMemo(() => {
// Backend only gives aggregated by_hour, so build a simple row from that
return DOWS.map(() => HOURS.map(() => 0))
}, [data])
const chartData = byHour.map(h => ({
hour: `${String(h.hour).padStart(2, '0')}:00`,
orders: h.orders,
}))
const busiest = [...byHour].sort((a, b) => b.orders - a.orders)[0]
const busiestDow = [...byWeekday].sort((a, b) => b.orders - a.orders)[0]
const quietest = [...byHour].filter(h => h.orders > 0).sort((a, b) => a.orders - b.orders)[0]
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={4} columns={5} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
const maxByHour = Math.max(1, ...byHour.map(h => h.orders))
const maxByDow = Math.max(1, ...byWeekday.map(d => d.orders))
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-4 gap-4">
<StatCard label="Πιο Πολυάσχολη Ώρα" value={busiest ? `${String(busiest.hour).padStart(2, '0')}:00` : '—'} icon={Flame} />
<StatCard label="Πιο Πολυάσχολη Μέρα" value={busiestDow?.label || '—'} icon={CalendarDays} />
<StatCard label="Πιο Ήσυχη Ώρα" value={quietest ? `${String(quietest.hour).padStart(2, '0')}:00` : '—'} icon={Moon} />
<StatCard label="Συνολικές Παραγγελίες" value={fmtNum(byHour.reduce((s, h) => s + h.orders, 0))} icon={TrendingUp} accent />
</div>
<div className="mt-4">
<Panel title="Παραγγελίες ανά Ώρα" subtitle="Σε όλες τις μέρες της επιλεγμένης περιόδου">
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="hour" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<Tooltip content={<ChartTooltip />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="orders" fill="#60a5fa" radius={[3, 3, 0, 0]} maxBarSize={36} />
</BarChart>
</ResponsiveContainer>
</div>
</Panel>
</div>
<div className="mt-4">
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
<div className="grid grid-cols-7 gap-3">
{byWeekday.map((d, i) => {
const intensity = d.orders / maxByDow
return (
<div key={d.day} className="flex flex-col items-center gap-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-wider text-slate-500">{d.label}</div>
<div
className="flex h-24 w-full items-end justify-center rounded-md pb-2"
style={{ backgroundColor: `rgba(96, 165, 250, ${0.08 + intensity * 0.7})` }}
>
<span className="font-mono text-[13px] font-semibold tabular-nums" style={{ color: intensity > 0.4 ? 'white' : '#334155' }}>
{d.orders}
</span>
</div>
</div>
)
})}
</div>
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, ChartTooltip } from '../shared/TablePrimitives'
import DrillDownModal from '../shared/DrillDownModal'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function WorkDaySummary() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [drillId, setDrillId] = useState(null)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['business-days', from, to],
queryFn: () => client.get('/api/reports/business-days', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data),
staleTime: 60 * 1000,
})
const { data: drillData } = useQuery({
queryKey: ['business-day-orders', drillId],
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: drillId, page_size: 200 } }).then(r => r.data),
enabled: !!drillId,
staleTime: 60 * 1000,
})
const days = data?.business_days || []
const drillDay = drillId ? days.find(d => d.id === drillId) : null
const drillOrders = Array.isArray(drillData) ? drillData : []
const chartData = [...days].reverse().map(d => ({
date: fmtDate(d.opened_at),
revenue: d.revenue,
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/orders/export" params={{ from: from + 'T00:00:00', to: to + 'T23:59:59' }} filename={`workdays-${from}-to-${to}.csv`} />
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
{chartData.length > 0 && (
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${from}${to}`}>
<div style={{ height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Line type="monotone" dataKey="revenue" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
</LineChart>
</ResponsiveContainer>
</div>
</Panel>
)}
<div className="mt-4">
<Panel title="Εργάσιμες Μέρες" padded={false}>
{days.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν εργάσιμες μέρες" description="Δοκιμάστε ευρύτερο εύρος ημερομηνιών." />
) : (
<DataTable>
<THead>
<TH>Εργάσιμη Μέρα</TH>
<TH>Άνοιξε</TH>
<TH>Έκλεισε</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Σερβιτόροι</TH>
<TH>Κατάσταση</TH>
</THead>
<tbody>
{days.map(d => (
<TR key={d.id} onClick={() => setDrillId(d.id)} striped>
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
<TD mono>{fmtTime(d.opened_at)}</TD>
<TD mono>{d.closed_at ? fmtTime(d.closed_at) : '—'}</TD>
<TD mono align="right">{fmtDuration(d.opened_at, d.closed_at)}</TD>
<TD mono align="right">{fmtNum(d.order_count)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
<TD><StatusBadge status={d.status} pulse /></TD>
</TR>
))}
</tbody>
</DataTable>
)}
</Panel>
</div>
</div>
{drillDay && (
<DrillDownModal
title={`Εργάσιμη Μέρα · ${fmtDate(drillDay.opened_at)}`}
subtitle={`${drillOrders.length} παραγγελίες · ${fmtEUR(drillDay.revenue)} έσοδα`}
onClose={() => setDrillId(null)}
>
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{drillOrders.map(o => {
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
return (
<TR key={o.id} striped>
<TD mono>#{o.id}</TD>
<TD>{o.table_id}</TD>
<TD mono>{fmtDate(o.opened_at)} {fmtTime(o.opened_at)}</TD>
<TD mono>{o.closed_at ? fmtTime(o.closed_at) : '—'}</TD>
<TD mono align="right" className="font-semibold">{fmtEUR(total)}</TD>
<TD><StatusBadge status={o.status} /></TD>
</TR>
)
})}
</tbody>
</DataTable>
</DrillDownModal>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { X, ChevronLeft } from 'lucide-react'
export default function DrillDownModal({ title, subtitle, onClose, onBack, children }) {
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative max-h-[88vh] w-[960px] max-w-[94vw] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-slate-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between border-b border-slate-200 px-6 py-4">
<div className="flex items-center gap-3">
{onBack && (
<button
onClick={onBack}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[12px] text-slate-500 hover:bg-slate-100 hover:text-slate-700"
>
<ChevronLeft className="h-3.5 w-3.5" />
Πίσω
</button>
)}
<div>
<div className="text-[15px] font-semibold text-slate-900">{title}</div>
{subtitle && <div className="mt-0.5 text-[12px] text-slate-500">{subtitle}</div>}
</div>
</div>
<button
onClick={onClose}
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[76vh] overflow-auto">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Inbox } from 'lucide-react'
export default function EmptyState({ icon: Icon = Inbox, title, description, action }) {
return (
<div className="flex min-h-[300px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100">
<Icon className="h-5 w-5 text-slate-400" />
</div>
<div className="text-[14px] font-medium text-slate-900">{title}</div>
{description && <div className="mt-1 max-w-sm text-[13px] text-slate-500">{description}</div>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
export default function ExportButton({ endpoint, params = {}, filename = 'export.csv' }) {
const [loading, setLoading] = useState(false)
async function handleClick() {
setLoading(true)
try {
const response = await client.get(endpoint, {
params,
responseType: 'blob',
})
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('Αποτυχία εξαγωγής')
} finally {
setLoading(false)
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 disabled:opacity-60"
>
{loading
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Download className="h-3.5 w-3.5" />
}
Εξαγωγή CSV
</button>
)
}

View File

@@ -0,0 +1,203 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { INPUT_CLASS } from '../../../ui/tokens'
export function FilterBar({ children, right }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-6 py-3 flex-shrink-0">
<div className="flex flex-wrap items-center gap-2">{children}</div>
{right && <div className="flex items-center gap-2 flex-shrink-0">{right}</div>}
</div>
)
}
// ── Searchable custom dropdown ─────────────────────────────────────────────────
export function FilterSelect({ value, onChange, options, label, icon: Icon }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef(null)
const inputRef = useRef(null)
const listRef = useRef(null)
const selected = options.find(o => o.value === value)
const filtered = query.trim()
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
: options
// Close on outside click
useEffect(() => {
function handler(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false)
setQuery('')
}
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
// Focus search input when opening
useEffect(() => {
if (open && inputRef.current) inputRef.current.focus()
}, [open])
function handleSelect(val) {
onChange(val)
setOpen(false)
setQuery('')
}
function handleKeyDown(e) {
if (e.key === 'Escape') { setOpen(false); setQuery('') }
}
// Dropdown position: flip up if near bottom of viewport
const [dropUp, setDropUp] = useState(false)
const openDropdown = useCallback(() => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
setDropUp(rect.bottom + 280 > window.innerHeight)
}
setOpen(true)
}, [])
return (
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
{/* Trigger button */}
<button
type="button"
onClick={() => open ? (setOpen(false), setQuery('')) : openDropdown()}
className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer whitespace-nowrap`}
>
{Icon && <Icon className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" />}
{label && (
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
{label}
</span>
)}
<span className="text-[13px] text-slate-700 font-normal">
{selected?.label ?? '—'}
</span>
<svg className="ml-1 h-3.5 w-3.5 text-slate-400 flex-shrink-0 transition-transform" style={{ transform: open ? 'rotate(180deg)' : '' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{/* Dropdown panel */}
{open && (
<div
className={`absolute z-50 w-64 rounded-lg border border-slate-200 bg-white shadow-lg ring-1 ring-slate-100 ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}
style={{ minWidth: 'max-content' }}
>
{/* Search box — only shown when there are enough options */}
{options.length > 6 && (
<div className="border-b border-slate-100 px-3 py-2">
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-2.5 py-1.5">
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Αναζήτηση…"
className="flex-1 bg-transparent text-[12px] text-slate-700 placeholder-slate-400 outline-none"
/>
{query && (
<button onClick={() => setQuery('')} className="text-slate-400 hover:text-slate-600">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
)}
{/* Options list */}
<ul ref={listRef} className="max-h-64 overflow-y-auto py-1">
{filtered.length === 0 ? (
<li className="px-4 py-3 text-[12px] text-slate-400 text-center">Δεν βρέθηκαν αποτελέσματα</li>
) : (
filtered.map(o => {
const isActive = o.value === value
return (
<li key={o.value}>
<button
type="button"
onClick={() => handleSelect(o.value)}
className={`w-full px-4 py-2 text-left text-[13px] transition-colors flex items-center gap-2 ${
isActive
? 'bg-sky-50 text-sky-700 font-medium'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
{isActive && (
<svg className="h-3.5 w-3.5 text-sky-500 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{!isActive && <span className="w-3.5 flex-shrink-0" />}
<span className="truncate">{o.label}</span>
</button>
</li>
)
})
)}
</ul>
</div>
)}
</div>
)
}
// ── Date input ─────────────────────────────────────────────────────────────────
export function FilterDateInput({ value, onChange, label }) {
return (
<label className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}>
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{label && (
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
{label}
</span>
)}
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-transparent outline-none text-[13px] text-slate-700 font-normal"
/>
</label>
)
}
// ── Work Day / Date Range toggle ───────────────────────────────────────────────
export function WorkDayDateToggle({ mode, onChange }) {
return (
<div className="inline-flex items-center rounded-md border border-slate-200 bg-slate-50 p-0.5 shadow-[inset_0_1px_0_rgba(15,23,42,0.04)]">
{[
{ id: 'workday', label: 'Εργάσιμη Μέρα' },
{ id: 'range', label: 'Εύρος Ημερομηνιών' },
].map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-[12px] font-medium transition whitespace-nowrap ${
mode === opt.id
? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{opt.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,30 @@
export default function SkeletonTable({ rows = 8, columns = 5, showChart = false }) {
return (
<div className="space-y-4">
{showChart && (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="mb-3 h-4 w-40 rounded bg-slate-200 animate-pulse" />
<div className="h-52 rounded bg-slate-100 animate-pulse" />
</div>
)}
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="border-b border-slate-100 px-5 py-3">
<div className="h-4 w-32 rounded bg-slate-200 animate-pulse" />
</div>
<div className="divide-y divide-slate-100">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-5 py-3">
{Array.from({ length: columns }).map((_, j) => (
<div
key={j}
className="h-3 rounded bg-slate-100 animate-pulse"
style={{ width: `${60 + ((i * 7 + j * 13) % 40)}px`, animationDelay: `${(i + j) * 50}ms` }}
/>
))}
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
export default function StatCard({ label, value, sub, icon: Icon, accent, size = 'md' }) {
const sizes = {
sm: { val: 'text-2xl', pad: 'p-4' },
md: { val: 'text-[28px]', pad: 'p-5' },
lg: { val: 'text-4xl', pad: 'p-6' },
}
const s = sizes[size] || sizes.md
return (
<div className={`relative overflow-hidden rounded-lg border border-slate-200 bg-white ${s.pad} shadow-[0_1px_0_rgba(15,23,42,0.04)]`}>
<div className="flex items-start justify-between">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">{label}</div>
{Icon && <Icon className="h-4 w-4 text-slate-300" />}
</div>
<div className={`mt-2 font-mono ${s.val} font-medium tabular-nums tracking-tight ${accent ? 'text-sky-600' : 'text-slate-900'}`}>
{value ?? '—'}
</div>
{sub && <div className="mt-1 text-[12px] text-slate-500">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,136 @@
import { STATUS_STYLES, avatarColor } from '../../../ui/tokens'
export function Panel({ title, subtitle, right, children, padded = true }) {
return (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-[0_1px_0_rgba(15,23,42,0.04)]">
{(title || right) && (
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
{title && <div className="text-[13px] font-semibold text-slate-900">{title}</div>}
{subtitle && <div className="text-[12px] text-slate-500">{subtitle}</div>}
</div>
{right}
</div>
)}
<div className={padded ? 'p-5' : ''}>{children}</div>
</div>
)
}
export function DataTable({ children, className = '' }) {
return (
<div className={`overflow-auto ${className}`}>
<table className="w-full border-separate border-spacing-0 text-[13px]">
{children}
</table>
</div>
)
}
export function THead({ children }) {
return (
<thead className="sticky top-0 z-10 bg-slate-50/95 backdrop-blur">
<tr>{children}</tr>
</thead>
)
}
export function TH({ children, className = '', align = 'left' }) {
return (
<th className={`border-b border-slate-200 px-4 py-2.5 text-${align} text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 ${className}`}>
{children}
</th>
)
}
export function TR({ children, onClick, highlight, striped, className = '' }) {
return (
<tr
onClick={onClick}
className={`group ${striped ? 'odd:bg-white even:bg-slate-50/40' : 'bg-white'} ${
onClick ? 'cursor-pointer' : ''
} hover:bg-sky-50/40 transition-colors ${highlight ? 'bg-sky-50/30' : ''} ${className}`}
>
{children}
</tr>
)
}
export function TD({ children, className = '', mono, align = 'left', colSpan }) {
return (
<td
colSpan={colSpan}
className={`border-b border-slate-100 px-4 py-2.5 text-${align} text-slate-700 ${mono ? 'font-mono tabular-nums' : ''} ${className}`}
>
{children}
</td>
)
}
export function StatusBadge({ status, pulse }) {
const s = STATUS_STYLES[status] || STATUS_STYLES.closed
const label = status ? status.replace('_', ' ').replace('-', ' ') : 'unknown'
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-mono uppercase tracking-[0.08em] ring-1 ring-inset ${s.bg} ${s.text} ${s.ring}`}>
<span className={`relative inline-block h-1.5 w-1.5 rounded-full ${s.dot}`}>
{pulse && (status === 'active' || status === 'open') && (
<span className={`absolute inset-0 rounded-full ${s.dot} animate-ping opacity-75`} />
)}
</span>
{label}
</span>
)
}
export function WaiterAvatar({ name, id }) {
const cls = avatarColor(id ?? name ?? '0')
const initials = name
? name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
: '?'
return (
<div className="flex items-center gap-2.5">
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold flex-shrink-0 ${cls}`}>
{initials}
</div>
<span className="font-medium text-slate-900">{name || '—'}</span>
</div>
)
}
export function ChartTooltip({ active, payload, label, formatter, labelFormatter }) {
if (!active || !payload?.length) return null
return (
<div className="rounded-md border border-slate-200 bg-white px-3 py-2 text-[12px] shadow-lg">
{label != null && (
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-slate-500">
{labelFormatter ? labelFormatter(label) : label}
</div>
)}
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: p.color || p.fill }} />
<span className="text-slate-600">{p.name}:</span>
<span className="font-mono font-medium text-slate-900 tabular-nums">
{formatter ? formatter(p.value, p.name) : p.value}
</span>
</div>
))}
</div>
)
}
export function ErrorState({ message, onRetry }) {
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<div className="text-[13px] text-slate-600">{message || 'Something went wrong'}</div>
{onRetry && (
<button
onClick={onRetry}
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-[12px] font-medium text-slate-700 hover:bg-slate-50"
>
Retry
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,3 @@
// Re-export shim — all tokens have moved to src/ui/tokens.js.
// Existing report subpage imports continue to work unchanged.
export * from '../../../ui/tokens'

View File

@@ -0,0 +1,115 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function Activity() {
const [waiterId, setWaiterId] = useState('all')
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(waiterId !== 'all' ? { waiter_id: waiterId } : {}),
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['shifts-activity', waiterId, mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/shift/orders', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const waiterOptions = [{ value: 'all', label: 'Όλοι οι Σερβιτόροι' }, ...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name })))]
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const waiters = data?.waiters || []
const chartData = useMemo(() => waiters.map(w => ({
name: (w.waiter_name || '').split(' ')[0],
orders: w.orders,
})), [waiters])
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={5} columns={7} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterSelect value={waiterId} onChange={setWaiterId} options={waiterOptions} label="Σερβιτόρος" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
{waiters.length > 0 && (
<Panel title="Παραγγελίες ανά Σερβιτόρο" subtitle="Συνολικές παραγγελίες στην επιλεγμένη περίοδο">
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={80} />
<Tooltip content={<ChartTooltip />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="orders" fill="#60a5fa" radius={[0, 3, 3, 0]} barSize={18} />
</BarChart>
</ResponsiveContainer>
</div>
</Panel>
)}
<div className="mt-4">
<Panel title="Δραστηριότητα ανά Σερβιτόρο" padded={false}>
{waiters.length === 0 ? (
<EmptyState title="Δεν βρέθηκε δραστηριότητα" description="Δεν υπάρχουν παραγγελίες στην επιλεγμένη περίοδο." />
) : (
<DataTable>
<THead>
<TH>Σερβιτόρος</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Είδη</TH>
<TH align="right">Συνολική Αξία</TH>
</THead>
<tbody>
{waiters.sort((a, b) => b.total - a.total).map(w => (
<TR key={w.waiter_id} striped>
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
<TD mono align="right">{fmtNum(w.orders)}</TD>
<TD mono align="right">{fmtNum(w.items)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(w.total)}</TD>
</TR>
))}
</tbody>
</DataTable>
)}
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,125 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Wallet, HandCoins, Coins } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtDate, fmtTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function Payments() {
const [waiterId, setWaiterId] = useState('all')
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(waiterId !== 'all' ? { waiter_id: waiterId } : {}),
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['shifts-payments', waiterId, mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/shifts', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const waiterOptions = [{ value: 'all', label: 'Όλοι οι Σερβιτόροι' }, ...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name })))]
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const shifts = data?.shifts || []
const totals = shifts.reduce((acc, s) => {
acc.starting += s.starting_cash || 0
acc.collected += s.total_collected || 0
acc.owed += s.net_to_deliver || 0
return acc
}, { starting: 0, collected: 0, owed: 0 })
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton
endpoint="/api/reports/shifts/export"
params={queryParams}
filename={`shift-payments-${from}-to-${to}.csv`}
/>
}>
<FilterSelect value={waiterId} onChange={setWaiterId} options={waiterOptions} label="Σερβιτόρος" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-4 grid grid-cols-3 gap-4">
<StatCard label="Αρχικά Μετρητά" value={fmtEUR(totals.starting)} sub={`σε ${shifts.length} βάρδιες`} icon={Wallet} />
<StatCard label="Συνολικές Εισπράξεις" value={fmtEUR(totals.collected)} sub="από πληρωμένες παραγγελίες" icon={HandCoins} />
<StatCard label="Οφείλεται στον Διαχειριστή" value={fmtEUR(totals.owed)} sub="προς εκκαθάριση" icon={Coins} accent />
</div>
<Panel title="Εκκαθάριση ανά Βάρδια" padded={false}>
{shifts.length === 0 ? (
<EmptyState title="Δεν υπάρχει τίποτα" description="Δεν υπάρχουν βάρδιες στην επιλεγμένη περίοδο." />
) : (
<DataTable>
<THead>
<TH>Σερβιτόρος</TH>
<TH>Βάρδια</TH>
<TH align="right">Αρχικά Μετρητά</TH>
<TH align="right">Εισπράχθηκαν</TH>
<TH align="right">Σύνολο στην Τσέπη</TH>
<TH>Κατάσταση</TH>
</THead>
<tbody>
{shifts.map(s => (
<TR key={s.id} highlight={s.is_active} striped>
<TD><WaiterAvatar name={s.waiter_name} id={s.waiter_id} /></TD>
<TD mono className="text-slate-500">
{fmtDate(s.started_at)} · {fmtTime(s.started_at)}{s.ended_at ? fmtTime(s.ended_at) : 'now'}
</TD>
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
</TR>
))}
<tr className="bg-slate-50">
<TD colSpan={2} className="py-3 text-[11px] font-semibold uppercase tracking-wider text-slate-500">Σύνολα</TD>
<TD mono align="right" className="py-3 font-semibold text-slate-900">{fmtEUR(totals.starting)}</TD>
<TD mono align="right" className="py-3 font-semibold text-slate-900">{fmtEUR(totals.collected)}</TD>
<TD mono align="right" className="py-3 text-base font-semibold text-sky-700">{fmtEUR(totals.owed)}</TD>
<TD />
</tr>
</tbody>
</DataTable>
)}
</Panel>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { UserRoundX, ChevronRight } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtDateTime, fmtDuration } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() {
const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10)
}
export default function ShiftsOverview() {
const [waiterId, setWaiterId] = useState('all')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [expanded, setExpanded] = useState(null)
const { data: waitersData } = useQuery({
queryKey: ['meta-waiters'],
queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data),
staleTime: 5 * 60 * 1000,
})
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['shifts', waiterId, from, to],
queryFn: () => client.get('/api/reports/shifts', {
params: {
...(waiterId !== 'all' ? { waiter_id: waiterId } : {}),
from: from + 'T00:00:00',
to: to + 'T23:59:59',
},
}).then(r => r.data),
staleTime: 60 * 1000,
})
const waiterOptions = [
{ value: 'all', label: 'Όλοι οι Σερβιτόροι' },
...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name }))),
]
const shifts = data?.shifts || []
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={8} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={null}><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center">
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">Επανάληψη</button>
</div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton
endpoint="/api/reports/shifts/export"
params={{ ...(waiterId !== 'all' ? { waiter_id: waiterId } : {}), from: from + 'T00:00:00', to: to + 'T23:59:59' }}
filename={`shifts-${from}-to-${to}.csv`}
/>
}>
<FilterSelect value={waiterId} onChange={setWaiterId} options={waiterOptions} label="Σερβιτόρος" />
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel
title="Όλες οι Βάρδιες"
subtitle={`${shifts.length} βάρδιες · ${from}${to}`}
padded={false}
>
{shifts.length === 0 ? (
<EmptyState
icon={UserRoundX}
title="Δεν βρέθηκαν βάρδιες"
description="Δοκιμάστε να αλλάξετε το εύρος ημερομηνιών ή τον σερβιτόρο."
/>
) : (
<DataTable>
<THead>
<TH>Σερβιτόρος</TH>
<TH>Έναρξη</TH>
<TH>Λήξη</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Αρχικά Μετρητά</TH>
<TH align="right">Εισπράχθηκαν</TH>
<TH align="right">Οφείλει</TH>
<TH>Κατάσταση</TH>
<TH className="w-10" />
</THead>
<tbody>
{shifts.map(s => {
const isOpen = expanded === s.id
return [
<TR
key={`${s.id}-row`}
onClick={() => setExpanded(isOpen ? null : s.id)}
highlight={isOpen}
striped
>
<TD><WaiterAvatar name={s.waiter_name} id={s.waiter_id} /></TD>
<TD mono>{fmtDateTime(s.started_at)}</TD>
<TD mono>
{s.ended_at
? fmtDateTime(s.ended_at)
: <span className="text-sky-600 font-medium"> ενεργή </span>}
</TD>
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right">
<ChevronRight className={`h-4 w-4 text-slate-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</TD>
</TR>,
isOpen && (
<tr key={`${s.id}-detail`}>
<td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<div className="text-[12px] text-slate-500">
Εργάσιμη Μέρα ID: {s.business_day_id ?? '—'} · Σημειώσεις: {s.notes || '—'}
</div>
</td>
</tr>
),
]
})}
</tbody>
</DataTable>
)}
</Panel>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, avatarColor } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
const PODIUM_HEIGHTS = ['h-44', 'h-32', 'h-24']
const PODIUM_COLORS = [
'bg-gradient-to-b from-amber-200 to-amber-100 ring-amber-300',
'bg-gradient-to-b from-slate-200 to-slate-100 ring-slate-300',
'bg-gradient-to-b from-orange-200 to-orange-100 ring-orange-300',
]
const MEDAL_LABEL = ['1ος', '2ος', '3ος']
const PODIUM_ORDER = [1, 0, 2]
export default function StaffLeaderboard() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['shifts-leaderboard', from, to],
queryFn: () => client.get('/api/reports/shifts', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data),
staleTime: 60 * 1000,
})
const ranked = useMemo(() => {
const shifts = data?.shifts || []
const map = new Map()
shifts.forEach(s => {
const cur = map.get(s.waiter_id) || { waiter_id: s.waiter_id, waiter_name: s.waiter_name, shifts: 0, orders: 0, value: 0 }
cur.shifts += 1
cur.value += s.total_collected || 0
map.set(s.waiter_id, cur)
})
return [...map.values()]
.map(r => ({ ...r, avg_per_shift: r.shifts ? r.value / r.shifts : 0 }))
.sort((a, b) => b.value - a.value)
}, [data])
const top3 = ranked.slice(0, 3)
const chartData = ranked.map(r => ({
name: (r.waiter_name || '').split(' ')[0],
value: Math.round(r.value),
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<span className="ml-2 text-[12px] text-slate-500">{ranked.length} σερβιτόροι · άθροισμα περιόδου</span>
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Κορυφαίοι" subtitle={`${from}${to}`}>
{top3.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
) : (
<div className="flex items-end justify-center gap-6 px-12 pb-2 pt-6">
{PODIUM_ORDER.map(rank => {
const r = top3[rank]
if (!r) return <div key={rank} className="w-44" />
const initials = (r.waiter_name || '').split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
const cls = avatarColor(r.waiter_id)
return (
<div key={r.waiter_id} className="flex w-44 flex-col items-center">
<div className="mb-3 flex flex-col items-center">
<div className={`flex h-14 w-14 items-center justify-center rounded-full text-base font-semibold ring-2 ring-white ${cls}`}>
{initials}
</div>
<div className="mt-2 text-[13px] font-semibold text-slate-900">{r.waiter_name}</div>
<div className="font-mono text-[11px] uppercase tracking-wider text-slate-500">{r.shifts} βάρδιες</div>
</div>
<div className={`flex w-full ${PODIUM_HEIGHTS[rank]} flex-col items-center justify-end rounded-t-md ring-1 ring-inset ${PODIUM_COLORS[rank]} pb-3`}>
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-700">{MEDAL_LABEL[rank]}</div>
<div className="mt-1 font-mono text-[18px] font-medium tabular-nums text-slate-900">{fmtEUR(r.value)}</div>
</div>
</div>
)
})}
</div>
)}
</Panel>
<div className="mt-4 grid grid-cols-3 gap-4">
<div className="col-span-2">
<Panel title="Πλήρης Κατάταξη" padded={false}>
<DataTable>
<THead>
<TH align="center" className="w-12">Θέση</TH>
<TH>Σερβιτόρος</TH>
<TH align="right">Βάρδιες</TH>
<TH align="right">Συνολικά Έσοδα</TH>
<TH align="right">Μέσος / Βάρδια</TH>
</THead>
<tbody>
{ranked.map((r, i) => (
<TR key={r.waiter_id} striped>
<TD align="center" className="w-12">
<span className={`inline-flex h-6 w-6 items-center justify-center rounded-full font-mono text-[11px] font-semibold ${
i === 0 ? 'bg-amber-100 text-amber-800 ring-1 ring-inset ring-amber-300' :
i === 1 ? 'bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-300' :
i === 2 ? 'bg-orange-100 text-orange-800 ring-1 ring-inset ring-orange-300' :
'text-slate-400'
}`}>{i + 1}</span>
</TD>
<TD><WaiterAvatar name={r.waiter_name} id={r.waiter_id} /></TD>
<TD mono align="right">{r.shifts}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(r.value)}</TD>
<TD mono align="right">{fmtEUR(r.avg_per_shift)}</TD>
</TR>
))}
</tbody>
</DataTable>
</Panel>
</div>
<Panel title="Έσοδα ανά Σερβιτόρο" subtitle="Φθίνουσα σειρά">
<div style={{ height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 16, bottom: 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={64} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="value" fill="#60a5fa" radius={[0, 3, 3, 0]} barSize={14} />
</BarChart>
</ResponsiveContainer>
</div>
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { create } from 'zustand'
const useAuthStore = create((set) => ({
user: null,
token: localStorage.getItem('manager_token') || null,
savedUsername: localStorage.getItem('manager_username') || null,
locked: sessionStorage.getItem('manager_locked') === 'true',
login(user, token) {
localStorage.setItem('manager_token', token)
localStorage.setItem('manager_username', user.username)
sessionStorage.removeItem('manager_locked')
set({ user, token, savedUsername: user.username, locked: false })
},
// Restores user after a page refresh without touching the lock state.
rehydrate(user, token) {
set({ user, token, savedUsername: user.username })
},
logout() {
localStorage.removeItem('manager_token')
localStorage.removeItem('manager_username')
localStorage.removeItem('manager_lock_timeout')
sessionStorage.removeItem('manager_locked')
set({ user: null, token: null, savedUsername: null, locked: false })
},
lock() {
sessionStorage.setItem('manager_locked', 'true')
set({ locked: true })
},
unlock(user, token) {
localStorage.setItem('manager_token', token)
sessionStorage.removeItem('manager_locked')
set({ user, token, locked: false })
},
updateUser(updatedUser) {
localStorage.setItem('manager_username', updatedUser.username)
set({ user: updatedUser })
},
}))
export default useAuthStore

View File

@@ -0,0 +1,94 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Mirrors waiter_pwa/src/store/tableColourStore.js — same localStorage key so both apps share state.
export const DEFAULT_COLOURS = {
light: {
free: {
cardBg: '#d6d6d6',
badgeBg: '#e3e3e3',
nameText: '#3b485e',
badgeText: '#adadad',
},
mine: {
cardBg: '#e83030',
badgeBg: 'rgba(255,255,255,0.40)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
open: {
cardBg: '#ffbb29',
badgeBg: 'rgba(255,255,255,0.25)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
partially_paid: {
cardBg: '#e89230',
badgeBg: 'rgba(255,255,255,0.25)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
paid: {
cardBg: '#79ad38',
badgeBg: 'rgba(255,255,255,0.25)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
},
dark: {
free: {
cardBg: '#243044',
badgeBg: 'rgba(26,35,50,0.50)',
nameText: '#ffffff',
badgeText: '#adadad',
},
mine: {
cardBg: '#e83030',
badgeBg: 'rgba(255,255,255,0.40)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
open: {
cardBg: '#ffbb29',
badgeBg: 'rgba(255,255,255,0.25)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
partially_paid: {
cardBg: '#e89230',
badgeBg: 'rgba(255,255,255,0.25)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
paid: {
cardBg: '#79ad38',
badgeBg: 'rgba(255,255,255,0.25)',
nameText: '#ffffff',
badgeText: '#ffffff',
},
},
}
const useTableColourStore = create(persist(
(set) => ({
colours: DEFAULT_COLOURS,
setColour: (mode, status, slot, value) =>
set(s => ({
colours: {
...s.colours,
[mode]: {
...s.colours[mode],
[status]: {
...s.colours[mode][status],
[slot]: value,
},
},
},
})),
resetAll: () => set({ colours: DEFAULT_COLOURS }),
}),
{ name: 'pos-table-colours' }
))
export default useTableColourStore

View File

@@ -0,0 +1,35 @@
import { STATUS_STYLES } from './tokens'
// Unified StatusBadge — replaces both src/components/StatusBadge.jsx
// and the StatusBadge in TablePrimitives.jsx.
//
// Greek labels map to the canonical status keys used across the app.
const LABELS = {
free: 'Ελεύθερο',
open: 'Ανοιχτό',
active: 'Ενεργό',
partially_paid: 'Μερική πληρωμή',
paid: 'Πληρώθηκε',
closed: 'Κλειστό',
'force-closed': 'Αναγκαστικό κλείσιμο',
cancelled: 'Ακυρώθηκε',
completed: 'Ολοκληρώθηκε',
failed: 'Απέτυχε',
success: 'Επιτυχία',
}
export default function Badge({ status, pulse = false }) {
const s = STATUS_STYLES[status] || STATUS_STYLES.closed
const label = LABELS[status] ?? (status ? status.replace(/[_-]/g, ' ') : 'άγνωστο')
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-mono uppercase tracking-[0.08em] ring-1 ring-inset ${s.bg} ${s.text} ${s.ring}`}>
<span className={`relative inline-block h-1.5 w-1.5 rounded-full flex-shrink-0 ${s.dot}`}>
{pulse && (status === 'active' || status === 'open') && (
<span className={`absolute inset-0 rounded-full ${s.dot} animate-ping opacity-75`} />
)}
</span>
{label}
</span>
)
}

View File

@@ -0,0 +1,23 @@
import { BUTTON_VARIANTS, BUTTON_SIZES } from './tokens'
export default function Button({
variant = 'secondary',
size = 'md',
children,
className = '',
...props
}) {
const base = BUTTON_VARIANTS[variant] ?? BUTTON_VARIANTS.secondary
const sizeOverride = BUTTON_SIZES[size]
// Replace the size-specific px/py/text classes from the base with the size override
const cls = size === 'sm'
? base.replace('px-3.5 py-2 text-[13px]', sizeOverride)
: base
return (
<button className={`${cls} ${className}`} {...props}>
{children}
</button>
)
}

View File

@@ -0,0 +1,39 @@
// Card / Panel primitive — wraps the PANEL_CLASS pattern with an optional header slot.
export function Card({ title, subtitle, right, children, padded = true, className = '' }) {
return (
<div className={`overflow-hidden rounded-lg border border-slate-200 bg-white shadow-[0_1px_0_rgba(15,23,42,0.04)] ${className}`}>
{(title || right) && (
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
{title && <div className="text-[13px] font-semibold text-slate-900">{title}</div>}
{subtitle && <div className="text-[12px] text-slate-500">{subtitle}</div>}
</div>
{right}
</div>
)}
<div className={padded ? 'p-5' : ''}>{children}</div>
</div>
)
}
export function StatCard({ label, value, sub, icon: Icon, accent, size = 'md', className = '' }) {
const sizes = {
sm: { val: 'text-2xl', pad: 'p-4' },
md: { val: 'text-[28px]', pad: 'p-5' },
lg: { val: 'text-4xl', pad: 'p-6' },
}
const s = sizes[size] || sizes.md
return (
<div className={`relative overflow-hidden rounded-lg border border-slate-200 bg-white ${s.pad} shadow-[0_1px_0_rgba(15,23,42,0.04)] ${className}`}>
<div className="flex items-start justify-between">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">{label}</div>
{Icon && <Icon className="h-4 w-4 text-slate-300" />}
</div>
<div className={`mt-2 font-mono ${s.val} font-medium tabular-nums tracking-tight ${accent ? 'text-sky-600' : 'text-slate-900'}`}>
{value ?? '—'}
</div>
{sub && <div className="mt-1 text-[12px] text-slate-500">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { INPUT_CLASS } from './tokens'
// Text input
export function Input({ className = '', ...props }) {
return (
<div className={`${INPUT_CLASS} ${className}`}>
<input
className="flex-1 bg-transparent outline-none text-[13px] text-slate-700 placeholder-slate-400 min-w-0"
{...props}
/>
</div>
)
}
// Native <select> wrapper
export function Select({ children, className = '', ...props }) {
return (
<div className={`${INPUT_CLASS} ${className}`}>
<select
className="flex-1 bg-transparent outline-none text-[13px] text-slate-700 cursor-pointer min-w-0"
{...props}
>
{children}
</select>
</div>
)
}
// Date input
export function DateInput({ label, icon: Icon, className = '', ...props }) {
return (
<label className={`${INPUT_CLASS} cursor-pointer ${className}`}>
{Icon && <Icon className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" />}
{label && (
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
{label}
</span>
)}
<input
type="date"
className="bg-transparent outline-none text-[13px] text-slate-700 font-normal min-w-0"
{...props}
/>
</label>
)
}
// Labelled text input with optional leading icon
export function LabelledInput({ label, icon: Icon, className = '', ...props }) {
return (
<div className={`${INPUT_CLASS} ${className}`}>
{Icon && <Icon className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" />}
{label && (
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
{label}
</span>
)}
<input
className="flex-1 bg-transparent outline-none text-[13px] text-slate-700 placeholder-slate-400 min-w-0"
{...props}
/>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import Button from './Button'
// General-purpose modal — replaces ConfirmModal and can wrap any content.
//
// Usage (confirm pattern):
// <Modal title="Διαγραφή" onClose={onCancel}>
// <p>Θέλετε σίγουρα να διαγράψετε αυτό το στοιχείο;</p>
// <Modal.Footer>
// <Button variant="secondary" onClick={onCancel}>Ακύρωση</Button>
// <Button variant="danger" onClick={onConfirm}>Διαγραφή</Button>
// </Modal.Footer>
// </Modal>
export default function Modal({ title, onClose, children, maxWidth = 'max-w-sm', className = '' }) {
// Close on Escape
useEffect(() => {
function handler(e) {
if (e.key === 'Escape') onClose?.()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.() }}
>
<div className={`relative w-full ${maxWidth} rounded-xl border border-slate-200 bg-white shadow-xl ${className}`}>
{/* Header */}
{title && (
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">{title}</h2>
{onClose && (
<button
onClick={onClose}
className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)}
{/* Body */}
<div className="px-5 py-4 space-y-3 text-[13px] text-slate-700">
{children}
</div>
</div>
</div>
)
}
// Footer helper — renders a right-aligned row of action buttons
function ModalFooter({ children }) {
return (
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-4 mt-2">
{children}
</div>
)
}
Modal.Footer = ModalFooter
// Convenience: ConfirmModal wrapping Modal for the most common pattern
export function ConfirmModal({
title,
message,
confirmLabel = 'Επιβεβαίωση',
confirmVariant = 'danger',
onConfirm,
onCancel,
}) {
return (
<Modal title={title} onClose={onCancel}>
{message && <p className="text-[13px] text-slate-600">{message}</p>}
<Modal.Footer>
<Button variant="secondary" onClick={onCancel}>Ακύρωση</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
)
}

View File

@@ -0,0 +1,85 @@
// Unified tab system — identical appearance across every page.
//
// TabGroup → primary nav row (supports lucide icons, used in Reports + Management + Settings)
// TabBar → secondary sub-tab row (pill style, used below TabGroup in Reports)
// TabRow → alias for TabGroup without icons — exact same markup and spacing
// TabCard → white rounded-xl card shell that wraps tab chrome + content
// Use this on any page that has tabs so it sits in the p-6 grey area correctly.
//
// Rule: ALL tab bars use px-6 pt-2 pb-0, border-b border-slate-200, transparent bg (inherits page bg).
// The sky-500 underline indicator is the ONLY active state difference.
const TAB_BTN_BASE =
'relative flex items-center gap-2 px-4 py-3 text-[13.5px] font-medium transition-colors whitespace-nowrap'
const TAB_BTN_ACTIVE = 'text-slate-900'
const TAB_BTN_INACTIVE = 'text-slate-500 hover:text-slate-700'
// ─── Primary tab row (with optional icons) ───────────────────────────────────
export function TabGroup({ tabs, active, onChange }) {
return (
<div className="flex-shrink-0 flex border-b border-slate-200 px-6 pt-2 overflow-x-auto">
{tabs.map((tab) => {
const isActive = tab.id === active
const Icon = tab.icon ?? tab.Icon
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`${TAB_BTN_BASE} ${isActive ? TAB_BTN_ACTIVE : TAB_BTN_INACTIVE}`}
>
{Icon && <Icon className={`h-4 w-4 flex-shrink-0 ${isActive ? 'text-sky-500' : 'text-slate-400'}`} />}
{tab.label}
{isActive && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-sky-500" />}
</button>
)
})}
</div>
)
}
// ─── Secondary sub-tab row (pill style) ──────────────────────────────────────
export function TabBar({ tabs, active, onChange }) {
return (
<div className="flex-shrink-0 flex items-center gap-1 border-b border-slate-200 px-6 py-2 overflow-x-auto">
{tabs.map((tab) => {
const isActive = tab.id === active
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-[12px] font-medium transition-colors ${
isActive
? 'bg-sky-100 text-sky-700 font-semibold'
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-700'
}`}
>
{tab.label}
</button>
)
})}
</div>
)
}
// ─── TabRow: identical to TabGroup, kept as named alias ──────────────────────
export function TabRow({ tabs, active, onChange }) {
return <TabGroup tabs={tabs} active={active} onChange={onChange} />
}
// ─── TabCard: the white card shell for any tabbed page ───────────────────────
// Renders a rounded-xl bordered white card that fills the available space.
// Place TabGroup/TabBar as direct children, then a flex-1 overflow content area.
//
// Usage:
// <TabCard>
// <TabGroup ... />
// <TabBar ... /> {/* optional */}
// <div className="flex-1 overflow-y-auto p-6"> ... </div>
// </TabCard>
export function TabCard({ children, className = '' }) {
return (
<div className={`flex flex-col flex-1 min-h-0 rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden ${className}`}>
{children}
</div>
)
}

View File

@@ -0,0 +1,7 @@
export { default as Button } from './Button'
export { TabGroup, TabBar, TabRow, TabCard } from './Tabs'
export { Card, StatCard } from './Card'
export { default as Badge } from './Badge'
export { Input, Select, DateInput, LabelledInput } from './Input'
export { default as Modal, ConfirmModal } from './Modal'
export * from './tokens'

View File

@@ -0,0 +1,143 @@
// Global design tokens — single source of truth for the manager dashboard UI
// Reports subsystem and all other pages import from here.
export const COLORS = {
// Primary accent
sky500: '#0ea5e9',
sky400: '#38bdf8',
sky600: '#0284c7',
sky50: '#f0f9ff',
// Neutrals
slate900: '#0f172a',
slate800: '#1e293b',
slate700: '#334155',
slate600: '#475569',
slate500: '#64748b',
slate400: '#94a3b8',
slate300: '#cbd5e1',
slate200: '#e2e8f0',
slate100: '#f1f5f9',
slate50: '#f8fafc',
white: '#ffffff',
// Semantic
emerald600: '#059669',
emerald700: '#047857',
emerald50: '#ecfdf5',
emerald200: '#a7f3d0',
rose600: '#e11d48',
rose700: '#be123c',
rose50: '#fff1f2',
rose200: '#fecdd3',
amber500: '#f59e0b',
amber50: '#fffbeb',
amber200: '#fde68a',
violet500: '#8b5cf6',
teal500: '#14b8a6',
}
export const CHART_PALETTE = [
'#60a5fa',
'#7fa67f',
'#dc6a3d',
'#c08aa8',
'#6b8fb5',
'#f59e0b',
'#14b8a6',
'#a78bfa',
]
export const STATUS_STYLES = {
completed: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' },
active: { bg: 'bg-sky-50', text: 'text-sky-700', ring: 'ring-sky-200', dot: 'bg-sky-500' },
'force-closed': { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' },
closed: { bg: 'bg-slate-100', text: 'text-slate-600', ring: 'ring-slate-200', dot: 'bg-slate-400' },
paid: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' },
open: { bg: 'bg-sky-50', text: 'text-sky-700', ring: 'ring-sky-200', dot: 'bg-sky-500' },
partially_paid: { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' },
cancelled: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' },
failed: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' },
success: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' },
free: { bg: 'bg-slate-100', text: 'text-slate-600', ring: 'ring-slate-200', dot: 'bg-slate-400' },
}
export const AVATAR_PALETTE = [
'bg-sky-100 text-sky-700',
'bg-emerald-100 text-emerald-700',
'bg-amber-100 text-amber-800',
'bg-violet-100 text-violet-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
]
export function avatarColor(id) {
const key = String(id)
let h = 0
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
}
// Formatters
export const fmtEUR = (n) => {
if (n == null) return '—'
return '€' + Number(n).toLocaleString('en-EU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export const fmtNum = (n) => (n == null ? '—' : Number(n).toLocaleString('en-EU'))
export const fmtTime = (iso) => {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
export const fmtDate = (iso) => {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
}
export const fmtDateTime = (iso) => {
if (!iso) return '—'
return fmtDate(iso) + ' · ' + fmtTime(iso)
}
export const fmtDuration = (startIso, endIso) => {
if (!startIso) return '—'
const start = new Date(startIso)
const end = endIso ? new Date(endIso) : new Date()
const minutes = Math.floor((end - start) / 60000)
if (minutes < 0) return '—'
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}h ${String(m).padStart(2, '0')}m`
}
export const fmtMinutes = (mins) => {
if (mins == null) return '—'
const h = Math.floor(mins / 60)
const m = Math.round(mins % 60)
return h > 0 ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`
}
// Common Tailwind class strings
export const PANEL_CLASS = 'overflow-hidden rounded-lg border border-slate-200 bg-white shadow-[0_1px_0_rgba(15,23,42,0.04)]'
export const STAT_CARD_CLASS = 'relative overflow-hidden rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]'
export const INPUT_CLASS = 'flex items-center gap-2 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100'
// Button variant class strings
export const BUTTON_VARIANTS = {
primary: 'inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-50 disabled:pointer-events-none',
secondary: 'inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300 active:bg-slate-100 disabled:opacity-50 disabled:pointer-events-none',
ghost: 'inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-[13px] font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 active:bg-slate-200 disabled:opacity-50 disabled:pointer-events-none',
danger: 'inline-flex items-center justify-center gap-2 rounded-md bg-rose-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-rose-600 active:bg-rose-700 disabled:opacity-50 disabled:pointer-events-none',
}
export const BUTTON_SIZES = {
sm: 'px-2.5 py-1.5 text-[12px]',
md: 'px-3.5 py-2 text-[13px]',
}
// Modal overlay
export const MODAL_OVERLAY_CLASS = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4'
// Sidebar nav item
export const NAV_ITEM_BASE = 'flex items-center gap-3 rounded-md px-3 py-2 text-[13px] font-medium transition'
export const NAV_ITEM_ACTIVE = 'bg-sky-50 text-sky-700'
export const NAV_ITEM_INACTIVE = 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#0f766e',
50: '#f0fdfa',
100: '#ccfbf1',
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
},
},
fontSize: {
base: ['1rem', { lineHeight: '1.5rem' }],
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5174,
},
})