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:
3
manager_dashboard/.dockerignore
Normal file
3
manager_dashboard/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
18
manager_dashboard/Dockerfile
Normal file
18
manager_dashboard/Dockerfile
Normal 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;"]
|
||||
15
manager_dashboard/index.html
Normal file
15
manager_dashboard/index.html
Normal 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>
|
||||
22
manager_dashboard/nginx.conf
Normal file
22
manager_dashboard/nginx.conf
Normal 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
3564
manager_dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
manager_dashboard/package.json
Normal file
32
manager_dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
manager_dashboard/postcss.config.js
Normal file
3
manager_dashboard/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
90
manager_dashboard/src/App.jsx
Normal file
90
manager_dashboard/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
manager_dashboard/src/api/client.js
Normal file
25
manager_dashboard/src/api/client.js
Normal 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
|
||||
14
manager_dashboard/src/components/ConfirmModal.jsx
Normal file
14
manager_dashboard/src/components/ConfirmModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
manager_dashboard/src/components/DateInput.jsx
Normal file
79
manager_dashboard/src/components/DateInput.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
manager_dashboard/src/components/EditProfileModal.jsx
Normal file
166
manager_dashboard/src/components/EditProfileModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
manager_dashboard/src/components/Sidebar.jsx
Normal file
47
manager_dashboard/src/components/Sidebar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
manager_dashboard/src/components/StatusBadge.jsx
Normal file
18
manager_dashboard/src/components/StatusBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
manager_dashboard/src/components/UserMenuButton.jsx
Normal file
56
manager_dashboard/src/components/UserMenuButton.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
manager_dashboard/src/hooks/useLicenseStatus.js
Normal file
84
manager_dashboard/src/hooks/useLicenseStatus.js
Normal 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,
|
||||
}
|
||||
}
|
||||
5
manager_dashboard/src/icons/add.svg
Normal file
5
manager_dashboard/src/icons/add.svg
Normal 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 |
8
manager_dashboard/src/icons/delete.svg
Normal file
8
manager_dashboard/src/icons/delete.svg
Normal 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 |
4
manager_dashboard/src/icons/edit.svg
Normal file
4
manager_dashboard/src/icons/edit.svg
Normal 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 |
5
manager_dashboard/src/icons/move-down.svg
Normal file
5
manager_dashboard/src/icons/move-down.svg
Normal 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 |
5
manager_dashboard/src/icons/move-up.svg
Normal file
5
manager_dashboard/src/icons/move-up.svg
Normal 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 |
36
manager_dashboard/src/index.css
Normal file
36
manager_dashboard/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
302
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
302
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, createContext, useContext } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Lock, AlertTriangle, ShieldAlert } from 'lucide-react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
import UserMenuButton from '../components/UserMenuButton'
|
||||
import EditProfileModal from '../components/EditProfileModal'
|
||||
import useLicenseStatus from '../hooks/useLicenseStatus'
|
||||
|
||||
export const LicenseContext = createContext(null)
|
||||
|
||||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||
|
||||
// ─── License Banner ───────────────────────────────────────────────────────────
|
||||
|
||||
function LicenseBanner({ license }) {
|
||||
const { lock_reason, locked, lock_pending, days_until_expiry, expires_at,
|
||||
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked } = license
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
// Admin lock (deferred or enforced)
|
||||
if (lock_reason === 'admin') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
{locked
|
||||
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
|
||||
: 'Εκκρεμεί κλείδωμα από διαχειριστή — θα ενεργοποιηθεί μετά το κλείσιμο της τρέχουσας ημέρας.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// License fully expired + grace over → blocked
|
||||
if (isBlocked && lock_reason === 'expired') {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// In grace period (expired but still allowed to operate)
|
||||
if (inGracePeriod) {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
const remaining = grace_days_remaining ?? '?'
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Expiry warning (≤5 days remaining)
|
||||
if (showExpiryWarning) {
|
||||
const days = days_until_expiry
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Lock Screen — PIN only, always. No password ever. ────────────────────────
|
||||
|
||||
function LockScreen({ username, displayName, onUnlock, onLogout }) {
|
||||
const [pin, setPin] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function pressDigit(d) {
|
||||
if (loading) return
|
||||
if (d === '⌫') { setPin(p => p.slice(0, -1)); setError(''); return }
|
||||
if (d === '') return
|
||||
if (pin.length >= 6) return
|
||||
setPin(p => p + d)
|
||||
}
|
||||
|
||||
async function submitPin(usedPin) {
|
||||
if (usedPin.length < 4) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/auth/login', { username, pin: usedPin })
|
||||
if (data.user.role !== 'manager' && data.user.role !== 'sysadmin') {
|
||||
setError('Not a manager account.')
|
||||
setPin('')
|
||||
return
|
||||
}
|
||||
onUnlock(data.user, data.access_token)
|
||||
} catch {
|
||||
setError('Wrong PIN')
|
||||
setPin('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pin.length === 4) submitPin(pin)
|
||||
}, [pin])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e) {
|
||||
if (loading) return
|
||||
if (e.key >= '0' && e.key <= '9') pressDigit(e.key)
|
||||
else if (e.key === 'Backspace') pressDigit('⌫')
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [pin, loading])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-xs text-center space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||
<Lock className="h-6 w-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-slate-900">Locked</p>
|
||||
<p className="text-[13px] text-slate-500">{displayName}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-center gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`w-3.5 h-3.5 rounded-full border-2 transition-colors ${
|
||||
i < pin.length ? 'bg-sky-500 border-sky-500' : 'border-slate-300'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{DIGITS.map((d, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => pressDigit(d)}
|
||||
disabled={d === '' || loading}
|
||||
className={`h-12 rounded-xl text-lg font-semibold transition-colors ${
|
||||
d === '' ? 'invisible'
|
||||
: d === '⌫' ? 'bg-slate-100 hover:bg-slate-200 text-slate-600'
|
||||
: 'bg-slate-100 hover:bg-sky-100 active:bg-sky-200 text-slate-800'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-rose-500">{error}</p>}
|
||||
{loading && <p className="text-[12px] text-slate-400">Verifying…</p>}
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-[12px] text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
Sign out instead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── AppLayout ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user, savedUsername, logout, lock, unlock, locked } = useAuthStore()
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const license = useLicenseStatus()
|
||||
|
||||
const { data: securitySettings = null } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
// Single ref object — updated every render so the interval always sees fresh values
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { user, locked, securitySettings, logout, lock, navigate }
|
||||
|
||||
const lastActivityRef = useRef(Date.now())
|
||||
|
||||
// ── Clock ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Single long-lived interval — never restarts ────────────────────────────
|
||||
useEffect(() => {
|
||||
function onActivity() { lastActivityRef.current = Date.now() }
|
||||
|
||||
const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click']
|
||||
EVENTS.forEach(ev => window.addEventListener(ev, onActivity, { passive: true }))
|
||||
|
||||
const id = setInterval(() => {
|
||||
const { user, locked, securitySettings, logout, lock, navigate } = stateRef.current
|
||||
const idle = (Date.now() - lastActivityRef.current) / 1000
|
||||
if (!user || !securitySettings) return
|
||||
|
||||
const get = (key, fb) => securitySettings?.[key]?.value ?? fb
|
||||
const autoLock = get('security.auto_lock', 'false') === 'true'
|
||||
const autoLogout = get('security.auto_logout', 'false') === 'true'
|
||||
if (!autoLock && !autoLogout) return
|
||||
|
||||
const lockSecs = autoLock ? parseInt(get('security.auto_lock_seconds', '300'), 10) : Infinity
|
||||
const logoutSecs = autoLogout ? parseInt(get('security.auto_logout_seconds', '1800'), 10) : Infinity
|
||||
|
||||
// Auto-logout runs regardless of lock state
|
||||
if (idle >= logoutSecs) {
|
||||
logout()
|
||||
navigate('/login', { replace: true, state: { manualLogout: true } })
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-lock only fires when not already locked
|
||||
if (!locked && idle >= lockSecs) {
|
||||
lock()
|
||||
}
|
||||
}, 1_000)
|
||||
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
EVENTS.forEach(ev => window.removeEventListener(ev, onActivity))
|
||||
}
|
||||
}, []) // runs once on mount, reads everything from stateRef
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login', { replace: true, state: { manualLogout: true } })
|
||||
}
|
||||
|
||||
function handleUnlock(u, t) {
|
||||
unlock(u, t)
|
||||
lastActivityRef.current = Date.now()
|
||||
}
|
||||
|
||||
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
const loginUsername = user?.username || savedUsername || ''
|
||||
const displayName = user?.full_name || loginUsername
|
||||
|
||||
return (
|
||||
<LicenseContext.Provider value={license}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{locked && loginUsername && (
|
||||
<LockScreen
|
||||
username={loginUsername}
|
||||
displayName={displayName || loginUsername}
|
||||
onUnlock={handleUnlock}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<LicenseBanner license={license} />
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0">
|
||||
<span className="text-[13px] font-semibold text-slate-600 tabular-nums">{timeStr}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={lock}
|
||||
title="Lock"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<UserMenuButton
|
||||
displayName={displayName}
|
||||
onEditProfile={() => setProfileOpen(true)}
|
||||
onSignOut={handleLogout}
|
||||
/>
|
||||
{profileOpen && <EditProfileModal onClose={() => setProfileOpen(false)} />}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</LicenseContext.Provider>
|
||||
)
|
||||
}
|
||||
21
manager_dashboard/src/main.jsx
Normal file
21
manager_dashboard/src/main.jsx
Normal 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>
|
||||
)
|
||||
788
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
788
manager_dashboard/src/pages/DashboardTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
344
manager_dashboard/src/pages/LoginPage.jsx
Normal file
344
manager_dashboard/src/pages/LoginPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
799
manager_dashboard/src/pages/Management/ProductFormModal.jsx
Normal file
799
manager_dashboard/src/pages/Management/ProductFormModal.jsx
Normal 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}> ↳ {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>
|
||||
)
|
||||
}
|
||||
1556
manager_dashboard/src/pages/Management/ProductsTab.jsx
Normal file
1556
manager_dashboard/src/pages/Management/ProductsTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
27
manager_dashboard/src/pages/ManagementPage.jsx
Normal file
27
manager_dashboard/src/pages/ManagementPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1654
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
1654
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
397
manager_dashboard/src/pages/OrderDetailPage.jsx
Normal file
397
manager_dashboard/src/pages/OrderDetailPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1564
manager_dashboard/src/pages/ProductsTab.jsx
Normal file
1564
manager_dashboard/src/pages/ProductsTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1261
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
1261
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
35
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
35
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
458
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
458
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal 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">Τραπέζια & Ζώνες</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="Εισαγωγή Τραπεζιών & Ζωνών"
|
||||
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>
|
||||
)
|
||||
}
|
||||
494
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
494
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal file
74
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
338
manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
Normal file
338
manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal file
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
270
manager_dashboard/src/pages/Settings/tabs/SecurityTab.jsx
Normal file
270
manager_dashboard/src/pages/Settings/tabs/SecurityTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
512
manager_dashboard/src/pages/SetupWizard.jsx
Normal file
512
manager_dashboard/src/pages/SetupWizard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
497
manager_dashboard/src/pages/StaffTab.jsx
Normal file
497
manager_dashboard/src/pages/StaffTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
495
manager_dashboard/src/pages/TablesConfigTab.jsx
Normal file
495
manager_dashboard/src/pages/TablesConfigTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
812
manager_dashboard/src/pages/TablesPage.jsx
Normal file
812
manager_dashboard/src/pages/TablesPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
manager_dashboard/src/pages/reports/ReportsPage.jsx
Normal file
91
manager_dashboard/src/pages/reports/ReportsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
175
manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
Normal file
175
manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
104
manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
Normal file
104
manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
166
manager_dashboard/src/pages/reports/restaurant/Today.jsx
Normal file
166
manager_dashboard/src/pages/reports/restaurant/Today.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
14
manager_dashboard/src/pages/reports/shared/EmptyState.jsx
Normal file
14
manager_dashboard/src/pages/reports/shared/EmptyState.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
manager_dashboard/src/pages/reports/shared/ExportButton.jsx
Normal file
42
manager_dashboard/src/pages/reports/shared/ExportButton.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
manager_dashboard/src/pages/reports/shared/FilterBar.jsx
Normal file
203
manager_dashboard/src/pages/reports/shared/FilterBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
manager_dashboard/src/pages/reports/shared/SkeletonTable.jsx
Normal file
30
manager_dashboard/src/pages/reports/shared/SkeletonTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
manager_dashboard/src/pages/reports/shared/StatCard.jsx
Normal file
20
manager_dashboard/src/pages/reports/shared/StatCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
manager_dashboard/src/pages/reports/shared/TablePrimitives.jsx
Normal file
136
manager_dashboard/src/pages/reports/shared/TablePrimitives.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
115
manager_dashboard/src/pages/reports/staff/Activity.jsx
Normal file
115
manager_dashboard/src/pages/reports/staff/Activity.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
manager_dashboard/src/pages/reports/staff/Payments.jsx
Normal file
125
manager_dashboard/src/pages/reports/staff/Payments.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
Normal file
143
manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
manager_dashboard/src/pages/reports/staff/StaffLeaderboard.jsx
Normal file
150
manager_dashboard/src/pages/reports/staff/StaffLeaderboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
manager_dashboard/src/store/authStore.js
Normal file
46
manager_dashboard/src/store/authStore.js
Normal 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
|
||||
94
manager_dashboard/src/store/tableColourStore.js
Normal file
94
manager_dashboard/src/store/tableColourStore.js
Normal 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
|
||||
35
manager_dashboard/src/ui/Badge.jsx
Normal file
35
manager_dashboard/src/ui/Badge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
manager_dashboard/src/ui/Button.jsx
Normal file
23
manager_dashboard/src/ui/Button.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
manager_dashboard/src/ui/Card.jsx
Normal file
39
manager_dashboard/src/ui/Card.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
manager_dashboard/src/ui/Input.jsx
Normal file
64
manager_dashboard/src/ui/Input.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
manager_dashboard/src/ui/Modal.jsx
Normal file
85
manager_dashboard/src/ui/Modal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
manager_dashboard/src/ui/Tabs.jsx
Normal file
85
manager_dashboard/src/ui/Tabs.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
manager_dashboard/src/ui/index.js
Normal file
7
manager_dashboard/src/ui/index.js
Normal 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'
|
||||
143
manager_dashboard/src/ui/tokens.js
Normal file
143
manager_dashboard/src/ui/tokens.js
Normal 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'
|
||||
23
manager_dashboard/tailwind.config.js
Normal file
23
manager_dashboard/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
10
manager_dashboard/vite.config.js
Normal file
10
manager_dashboard/vite.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user