Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3137
manager_dashboard/package-lock.json
generated
Normal file
3137
manager_dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import useAuthStore from './store/authStore'
|
||||
import AppLayout from './layouts/AppLayout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import OrderDetailPage from './pages/OrderDetailPage'
|
||||
import ProductsPage from './pages/ProductsPage'
|
||||
import WaitersPage from './pages/WaitersPage'
|
||||
import OperationsPage from './pages/OperationsPage'
|
||||
import TablesPage from './pages/TablesPage'
|
||||
import OrderDetailPage from './pages/OrderDetailPage'
|
||||
import ManagementPage from './pages/ManagementPage'
|
||||
import ReportsPage from './pages/ReportsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import SettingsPage from './pages/Settings/SettingsPage'
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const token = useAuthStore(s => s.token)
|
||||
@@ -21,12 +20,12 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="waiters" element={<WaitersPage />} />
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,7 @@ const BASE_URL = import.meta.env.VITE_API_URL || 'http://192.168.1.10:8000'
|
||||
const client = axios.create({ baseURL: BASE_URL })
|
||||
|
||||
client.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
const token = localStorage.getItem('manager_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
@@ -14,7 +14,10 @@ client.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
// 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)
|
||||
|
||||
@@ -2,12 +2,11 @@ import { NavLink } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', icon: '📊', label: 'Dashboard' },
|
||||
{ to: '/tables', icon: '🪑', label: 'Τραπέζια' },
|
||||
{ to: '/products', icon: '📦', label: 'Προϊόντα' },
|
||||
{ to: '/waiters', icon: '👥', label: 'Σερβιτόροι' },
|
||||
{ to: '/reports', icon: '📋', label: 'Αναφορές' },
|
||||
{ to: '/settings', icon: '⚙️', label: 'Ρυθμίσεις' },
|
||||
{ to: '/operations', icon: '📊', label: 'Διοίκηση' },
|
||||
{ to: '/tables', icon: '🪑', label: 'Τραπέζια' },
|
||||
{ to: '/reports', icon: '📋', label: 'Αναφορές' },
|
||||
{ to: '/management', icon: '🗂️', label: 'Διαχείριση' },
|
||||
{ to: '/settings', icon: '⚙️', label: 'Ρυθμίσεις' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
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 |
@@ -16,7 +16,7 @@
|
||||
@apply bg-primary-700 hover:bg-primary-800 text-white;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 hover:bg-gray-200 text-gray-700;
|
||||
@apply bg-gray-200 hover:bg-gray-300 text-gray-700;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||
|
||||
@@ -1,38 +1,219 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user, token, login, logout } = useAuthStore()
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const SETTINGS_KEY = 'manager_lock_timeout'
|
||||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||
|
||||
// Fetch user profile once on mount if token exists but user isn't loaded
|
||||
// ─── Lock Screen overlay ───────────────────────────────────────────────────────
|
||||
|
||||
function LockScreen({ username, onUnlock }) {
|
||||
const [pin, setPin] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function pressDigit(d) {
|
||||
if (d === '⌫') { setPin(p => p.slice(0, -1)); setError(''); return }
|
||||
if (d === '') return
|
||||
if (pin.length >= 6) return
|
||||
setPin(p => p + d)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (pin.length < 4) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/auth/login', { username, pin })
|
||||
const role = data.user.role
|
||||
if (role !== 'manager' && role !== 'sysadmin') {
|
||||
setError('Δεν έχεις δικαιώματα διαχειριστή.')
|
||||
setPin('')
|
||||
return
|
||||
}
|
||||
onUnlock(data.user, data.access_token)
|
||||
} catch {
|
||||
setError('Λανθασμένο PIN')
|
||||
setPin('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-submit when 4 digits entered (most PINs are 4)
|
||||
useEffect(() => {
|
||||
if (pin.length === 4) handleSubmit()
|
||||
}, [pin])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(17,19,21,0.92)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 24,
|
||||
padding: '36px 32px', width: '100%', maxWidth: 340,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.4)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }}>🔒</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#111315', marginBottom: 4 }}>
|
||||
Κλειδωμένο
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: '#5a6169', marginBottom: 24 }}>
|
||||
{username}
|
||||
</div>
|
||||
|
||||
{/* PIN dots */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginBottom: 24 }}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} style={{
|
||||
width: 14, height: 14, borderRadius: '50%', border: '2px solid',
|
||||
borderColor: i < pin.length ? '#3758c9' : '#d1d5db',
|
||||
background: i < pin.length ? '#3758c9' : 'transparent',
|
||||
transition: 'all 120ms',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* PIN pad */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 16 }}>
|
||||
{DIGITS.map((d, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => pressDigit(d)}
|
||||
disabled={d === '' || loading}
|
||||
style={{
|
||||
height: 56, borderRadius: 14, border: 'none', cursor: d === '' ? 'default' : 'pointer',
|
||||
fontSize: 20, fontWeight: 600,
|
||||
background: d === '' ? 'transparent' : d === '⌫' ? '#f3f4f6' : '#f3f4f6',
|
||||
color: d === '⌫' ? '#6b7280' : '#111315',
|
||||
visibility: d === '' ? 'hidden' : 'visible',
|
||||
transition: 'background 80ms',
|
||||
}}
|
||||
onMouseDown={e => { if (d !== '') e.currentTarget.style.background = '#e5e7eb' }}
|
||||
onMouseUp={e => { if (d !== '') e.currentTarget.style.background = '#f3f4f6' }}
|
||||
onMouseLeave={e => { if (d !== '') e.currentTarget.style.background = '#f3f4f6' }}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: 13, color: '#dc2626', marginBottom: 8 }}>{error}</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p style={{ fontSize: 13, color: '#6b7280' }}>Επαλήθευση…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── AppLayout ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user, token, savedUsername, login, logout, lock, unlock, locked } = useAuthStore()
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const navigate = useNavigate()
|
||||
const lastActivityRef = useRef(Date.now())
|
||||
const lockTimerRef = useRef(null)
|
||||
|
||||
// ── Rehydrate user from token on mount ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (token && !user) {
|
||||
client.get('/auth/me').then(r => login(r.data, token)).catch(() => logout())
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// ── Clock ────────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Auto-lock timer ──────────────────────────────────────────────────────────
|
||||
const getTimeoutMs = useCallback(() => {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY)
|
||||
const mins = parseInt(raw, 10)
|
||||
if (!isNaN(mins) && mins > 0) return mins * 60 * 1000
|
||||
return null // 0 or unset = disabled
|
||||
}, [])
|
||||
|
||||
const resetActivityTimer = useCallback(() => {
|
||||
lastActivityRef.current = Date.now()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || locked) return
|
||||
|
||||
const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click']
|
||||
EVENTS.forEach(e => window.addEventListener(e, resetActivityTimer, { passive: true }))
|
||||
|
||||
function checkIdle() {
|
||||
const timeoutMs = getTimeoutMs()
|
||||
if (!timeoutMs) return
|
||||
if (Date.now() - lastActivityRef.current >= timeoutMs) {
|
||||
lock()
|
||||
}
|
||||
}
|
||||
|
||||
lockTimerRef.current = setInterval(checkIdle, 10_000)
|
||||
|
||||
return () => {
|
||||
EVENTS.forEach(e => window.removeEventListener(e, resetActivityTimer))
|
||||
clearInterval(lockTimerRef.current)
|
||||
}
|
||||
}, [user, locked, getTimeoutMs, resetActivityTimer, lock])
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
function handleUnlock(u, t) {
|
||||
unlock(u, t)
|
||||
}
|
||||
|
||||
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
const displayName = user?.username || savedUsername || ''
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Lock overlay — rendered on top of everything */}
|
||||
{locked && displayName && (
|
||||
<LockScreen username={displayName} onUnlock={handleUnlock} />
|
||||
)}
|
||||
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-gray-200 shrink-0">
|
||||
<span className="text-lg font-semibold text-gray-700 tabular-nums">{timeStr}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Lock button */}
|
||||
<button
|
||||
onClick={lock}
|
||||
title="Κλείδωμα"
|
||||
style={{
|
||||
height: 30, width: 30, borderRadius: 8,
|
||||
border: '1px solid #dfe2e6', background: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 15, cursor: 'pointer', color: '#5a6169',
|
||||
transition: 'background 120ms, color 120ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#f3f4f6'; e.currentTarget.style.color = '#374151' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.color = '#5a6169' }}
|
||||
>🔒</button>
|
||||
<span className="text-sm text-gray-500">{user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-red-600 hover:text-red-800 font-medium transition-colors"
|
||||
>
|
||||
Αποσύνδεση
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
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 = [], 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 — fixed height placeholder */}
|
||||
<div style={{ marginTop: 8, height: 22 }} />
|
||||
|
||||
{/* 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 navigate = useNavigate()
|
||||
|
||||
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 ? API_URL + 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'
|
||||
return { table, order, tableStatus }
|
||||
})
|
||||
|
||||
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="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Dashboard</h1>
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
{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 }) => {
|
||||
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}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
739
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
739
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
@@ -0,0 +1,739 @@
|
||||
import { useState } 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'
|
||||
|
||||
// ─── 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 { 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) => toast.error(e.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
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={() => openDayMut.mutate()}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
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 ? API_URL + 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="space-y-6">
|
||||
<BusinessDayPanel />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Dashboard</h1>
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
{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
|
||||
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>
|
||||
)
|
||||
}
|
||||
57
manager_dashboard/src/pages/ManagementPage.jsx
Normal file
57
manager_dashboard/src/pages/ManagementPage.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import ProductsTab from './ProductsTab'
|
||||
import TablesConfigTab from './TablesConfigTab'
|
||||
import StaffTab from './StaffTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'products', label: 'Προϊόντα' },
|
||||
{ key: 'tables', label: 'Τραπέζια' },
|
||||
{ key: 'staff', label: 'Προσωπικό' },
|
||||
]
|
||||
|
||||
export default function ManagementPage() {
|
||||
const [activeTab, setActiveTab] = useState('products')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 0 }}>
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 4,
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
paddingBottom: 0,
|
||||
marginBottom: 24,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: '0 20px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.key ? '2px solid #3758c9' : '2px solid transparent',
|
||||
background: 'transparent',
|
||||
color: activeTab === tab.key ? '#3758c9' : '#6b7280',
|
||||
fontSize: 14,
|
||||
fontWeight: activeTab === tab.key ? 700 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'color 120ms ease, border-color 120ms ease',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{activeTab === 'products' && <ProductsTab />}
|
||||
{activeTab === 'tables' && <TablesConfigTab />}
|
||||
{activeTab === 'staff' && <StaffTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1610
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
1610
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -122,7 +122,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||
})
|
||||
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||
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 = () => {
|
||||
@@ -138,13 +138,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
|
||||
const cancelOrder = useMutation({
|
||||
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
||||
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/dashboard') },
|
||||
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/tables') },
|
||||
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
||||
})
|
||||
|
||||
const closeOrder = useMutation({
|
||||
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
|
||||
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/dashboard') },
|
||||
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/tables') },
|
||||
onError: () => toast.error('Σφάλμα κλεισίματος'),
|
||||
})
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
{tab === 'overview' && <>
|
||||
{/* Waiters */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||
<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">
|
||||
@@ -239,13 +239,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
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.username}</option>
|
||||
<option key={w.id} value={w.id}>{w.nickname || w.full_name || w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -377,10 +377,13 @@ export default function ReportsPage() {
|
||||
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
|
||||
|
||||
const TABS = [
|
||||
['shift', 'Σύνοψη Πληρωμών Βάρδιας'],
|
||||
['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'],
|
||||
['printers', 'Σύνοψη εκτυπωτών'],
|
||||
['history', 'Ιστορικό παραγγελιών'],
|
||||
['shift', 'Πληρωμές Βάρδιας'],
|
||||
['shift-orders', 'Παραγγελίες Βάρδιας'],
|
||||
['shifts-history','Ιστορικό Βαρδιών'],
|
||||
['printers', 'Εκτυπωτές'],
|
||||
['history', 'Ιστορικό Παραγγελιών'],
|
||||
['product-perf', 'Απόδοση Προϊόντων'],
|
||||
['traffic', 'Ανάλυση Κίνησης'],
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -393,10 +396,13 @@ export default function ReportsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
||||
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
||||
{tab === 'printers' && <PrintersTab />}
|
||||
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||||
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
||||
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
||||
{tab === 'shifts-history' && <ShiftsHistoryTab />}
|
||||
{tab === 'printers' && <PrintersTab />}
|
||||
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||||
{tab === 'product-perf' && <ProductPerformanceTab />}
|
||||
{tab === 'traffic' && <TrafficTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -934,3 +940,324 @@ function HistoryTab({ filters, setFilters }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shifts History Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function ShiftsHistoryTab() {
|
||||
const [fromDt, setFromDt] = useState(todayStart())
|
||||
const [toDt, setToDt] = useState(todayEnd())
|
||||
const [waiterId, setWaiterId] = useState('')
|
||||
const [activeOnly, setActiveOnly] = useState(false)
|
||||
|
||||
const { data: waiters = [] } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({ from: fromDt, to: toDt })
|
||||
if (waiterId) params.set('waiter_id', waiterId)
|
||||
if (activeOnly) params.set('active_only', 'true')
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['report-shifts', fromDt, toDt, waiterId, activeOnly],
|
||||
queryFn: () => client.get(`/api/reports/shifts?${params}`).then(r => r.data),
|
||||
})
|
||||
|
||||
const rows = data?.shifts ?? []
|
||||
const grandTotal = rows.reduce((s, r) => s + (r.total_collected || 0), 0)
|
||||
const grandDeliver = rows.reduce((s, r) => s + (r.net_to_deliver || 0), 0)
|
||||
|
||||
const csvRows = rows.map(r => ({
|
||||
Σερβιτόρος: r.waiter_name,
|
||||
Έναρξη: fmtDt(r.started_at),
|
||||
Λήξη: fmtDt(r.ended_at),
|
||||
'Αρχικά (€)': r.starting_cash?.toFixed(2) ?? '',
|
||||
'Εισπράχθηκαν (€)': (r.total_collected || 0).toFixed(2),
|
||||
'Προς απόδοση (€)': (r.net_to_deliver || 0).toFixed(2),
|
||||
Κατάσταση: r.is_active ? 'Ενεργή' : 'Έκλεισε',
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
|
||||
<select className={SELECT + ' w-44'} 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>
|
||||
<label className="flex items-center gap-2 h-10 cursor-pointer select-none text-sm text-gray-700">
|
||||
<input type="checkbox" className="w-4 h-4 rounded accent-primary-700"
|
||||
checked={activeOnly} onChange={e => setActiveOnly(e.target.checked)} />
|
||||
Μόνο ενεργές
|
||||
</label>
|
||||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||
{rows.length > 0 && (
|
||||
<button onClick={() => csvDownload(csvRows, `shifts_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
|
||||
Εξαγωγή CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν βάρδιες.</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Έναρξη</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Λήξη</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Αρχικά (€)</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Εισπράχθηκαν (€)</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Προς Απόδοση (€)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600">Κατάσταση</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-800">{r.waiter_name}</td>
|
||||
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">{fmtDt(r.started_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">{r.ended_at ? fmtDt(r.ended_at) : '—'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">
|
||||
{r.starting_cash != null ? `€${r.starting_cash.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-gray-800">
|
||||
€{(r.total_collected || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-primary-700">
|
||||
€{(r.net_to_deliver || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.is_active
|
||||
? <span className="inline-flex items-center gap-1 text-xs font-semibold text-green-700 bg-green-50 px-2 py-0.5 rounded-full">● Ενεργή</span>
|
||||
: <span className="text-xs text-gray-400">Έκλεισε</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-gray-800">€{grandTotal.toFixed(2)}</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-primary-700">€{grandDeliver.toFixed(2)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Product Performance Tab ───────────────────────────────────────────────────
|
||||
|
||||
function ProductPerformanceTab() {
|
||||
const [fromDt, setFromDt] = useState(todayStart())
|
||||
const [toDt, setToDt] = useState(todayEnd())
|
||||
const [sortBy, setSortBy] = useState('qty_sold') // 'qty_sold' | 'revenue'
|
||||
|
||||
const params = new URLSearchParams({ from: fromDt, to: toDt })
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['product-performance', fromDt, toDt],
|
||||
queryFn: () => client.get(`/api/reports/products/performance?${params}`).then(r => r.data),
|
||||
})
|
||||
|
||||
const rows = [...(data?.products ?? [])].sort((a, b) => b[sortBy] - a[sortBy])
|
||||
const maxVal = rows.length > 0 ? rows[0][sortBy] : 1
|
||||
|
||||
const csvRows = rows.map((r, i) => ({
|
||||
Κατάταξη: i + 1,
|
||||
Προϊόν: r.product_name,
|
||||
'Τεμάχια': r.qty_sold,
|
||||
'Παραγγελίες': r.order_count,
|
||||
'Έσοδα (€)': r.revenue.toFixed(2),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Ταξινόμηση</label>
|
||||
<select className={SELECT + ' w-44'} value={sortBy} onChange={e => setSortBy(e.target.value)}>
|
||||
<option value="qty_sold">Τεμάχια</option>
|
||||
<option value="revenue">Έσοδα</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||
{rows.length > 0 && (
|
||||
<button onClick={() => csvDownload(csvRows, `products_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
|
||||
Εξαγωγή CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν δεδομένα.</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="text-center px-3 py-3 font-semibold text-gray-500 w-10">#</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Προϊόν</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Τεμάχια</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Έσοδα (€)</th>
|
||||
<th className="px-4 py-3 w-48" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{rows.map((r, i) => {
|
||||
const barPct = Math.round((r[sortBy] / maxVal) * 100)
|
||||
return (
|
||||
<tr key={r.product_id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2.5 text-center text-gray-400 font-mono text-xs">{i + 1}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-800">{r.product_name}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 font-mono">{r.qty_sold}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-500">{r.order_count}</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">€{r.revenue.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div style={{ height: 6, borderRadius: 999, background: '#e5e7eb', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${barPct}%`, height: '100%', borderRadius: 999,
|
||||
background: sortBy === 'revenue' ? '#7c3aed' : '#2563eb',
|
||||
transition: 'width 300ms ease',
|
||||
}} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Traffic Analysis Tab ──────────────────────────────────────────────────────
|
||||
|
||||
function TrafficTab() {
|
||||
const [fromDt, setFromDt] = useState(todayStart())
|
||||
const [toDt, setToDt] = useState(todayEnd())
|
||||
const [view, setView] = useState('hour') // 'hour' | 'weekday'
|
||||
|
||||
const params = new URLSearchParams({ from: fromDt, to: toDt })
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['traffic', fromDt, toDt],
|
||||
queryFn: () => client.get(`/api/reports/traffic?${params}`).then(r => r.data),
|
||||
})
|
||||
|
||||
const hourData = data?.by_hour ?? []
|
||||
const weekdayData = data?.by_weekday ?? []
|
||||
|
||||
const activeData = view === 'hour' ? hourData : weekdayData
|
||||
const maxOrders = Math.max(...activeData.map(d => d.orders), 1)
|
||||
const maxRevenue = Math.max(...activeData.map(d => d.revenue), 1)
|
||||
|
||||
function label(d) {
|
||||
if (view === 'hour') return `${String(d.hour).padStart(2,'0')}:00`
|
||||
return d.label
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setView('hour')} className={`h-10 px-3 rounded-l-lg border text-sm font-medium transition-colors ${view === 'hour' ? 'bg-primary-600 border-primary-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}>Ώρα</button>
|
||||
<button onClick={() => setView('weekday')} className={`h-10 px-3 rounded-r-lg border text-sm font-medium transition-colors ${view === 'weekday' ? 'bg-primary-600 border-primary-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}>Ημέρα</button>
|
||||
</div>
|
||||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Orders chart */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Παραγγελίες ανά {view === 'hour' ? 'ώρα' : 'ημέρα'}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{activeData.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-12 text-right shrink-0">{label(d)}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div style={{ flex: 1, height: 18, background: '#f3f4f6', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: d.orders === 0 ? '0%' : `${Math.max(2, (d.orders / maxOrders) * 100)}%`,
|
||||
height: '100%', background: '#2563eb', borderRadius: 4,
|
||||
transition: 'width 300ms ease',
|
||||
}} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-700 w-8 text-right">{d.orders}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue chart */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Έσοδα ανά {view === 'hour' ? 'ώρα' : 'ημέρα'}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{activeData.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-12 text-right shrink-0">{label(d)}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div style={{ flex: 1, height: 18, background: '#f3f4f6', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: d.revenue === 0 ? '0%' : `${Math.max(2, (d.revenue / maxRevenue) * 100)}%`,
|
||||
height: '100%', background: '#7c3aed', borderRadius: 4,
|
||||
transition: 'width 300ms ease',
|
||||
}} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-700 w-14 text-right">€{d.revenue.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
51
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import AppInfoTab from './tabs/AppInfoTab'
|
||||
import ColoursTab from './tabs/ColoursTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'app-info', label: 'App Info' },
|
||||
{ key: 'colours', label: 'UI Personalization' },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState('app-info')
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<h1 className="text-xl font-bold text-gray-800" style={{ marginBottom: 20 }}>Ρυθμίσεις</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 4,
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
marginBottom: 28,
|
||||
}}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.key ? '#3758c9' : '#6b7280',
|
||||
borderBottom: `2px solid ${activeTab === tab.key ? '#3758c9' : 'transparent'}`,
|
||||
marginBottom: -2,
|
||||
borderRadius: '6px 6px 0 0',
|
||||
transition: 'color 0.12s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'app-info' && <AppInfoTab />}
|
||||
{activeTab === 'colours' && <ColoursTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
541
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
541
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
@@ -0,0 +1,541 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
|
||||
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 ? '#16a34a' : '#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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const LOCK_TIMEOUT_OPTIONS = [
|
||||
{ label: 'Απενεργοποιημένο', value: 0 },
|
||||
{ label: '1 λεπτό', value: 1 },
|
||||
{ label: '5 λεπτά', value: 5 },
|
||||
{ label: '10 λεπτά', value: 10 },
|
||||
{ label: '15 λεπτά', value: 15 },
|
||||
{ label: '30 λεπτά', value: 30 },
|
||||
{ label: '60 λεπτά', value: 60 },
|
||||
]
|
||||
|
||||
const LOCK_SETTINGS_KEY = 'manager_lock_timeout'
|
||||
|
||||
function AutoLockSection() {
|
||||
const raw = parseInt(localStorage.getItem(LOCK_SETTINGS_KEY) || '0', 10)
|
||||
const [timeout, setTimeout_] = useState(isNaN(raw) ? 0 : raw)
|
||||
|
||||
function handleChange(val) {
|
||||
const n = parseInt(val, 10)
|
||||
setTimeout_(n)
|
||||
if (n > 0) {
|
||||
localStorage.setItem(LOCK_SETTINGS_KEY, String(n))
|
||||
} else {
|
||||
localStorage.removeItem(LOCK_SETTINGS_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
Αν δεν υπάρξει δραστηριότητα για το παρακάτω διάστημα, η οθόνη κλειδώνει και ζητάει PIN.
|
||||
Το 0 απενεργοποιεί το αυτόματο κλείδωμα.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center gap-4">
|
||||
<select
|
||||
value={timeout}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none w-52"
|
||||
>
|
||||
{LOCK_TIMEOUT_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{timeout > 0 && (
|
||||
<span className="text-xs text-green-700 font-medium bg-green-50 border border-green-200 rounded-lg px-3 py-1.5">
|
||||
Κλείδωμα μετά από {timeout} {timeout === 1 ? 'λεπτό' : 'λεπτά'} αδράνειας
|
||||
</span>
|
||||
)}
|
||||
{timeout === 0 && (
|
||||
<span className="text-xs text-gray-500">Μόνο χειροκίνητο κλείδωμα (κουμπί 🔒)</span>
|
||||
)}
|
||||
</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 (
|
||||
<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>
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Αυτόματη Έναρξη Βάρδιας</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Οι σερβιτόροι μπορούν να ξεκινούν μόνοι τους τη βάρδια τους</p>
|
||||
</div>
|
||||
<Toggle checked={selfStart === 'true'} onChange={() => toggle('shifts.waiter_self_start', selfStart)} disabled={updateMut.isPending} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Αυτόματο Κλείσιμο Βάρδιας</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους</p>
|
||||
</div>
|
||||
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 FlagDisplayModeSection() {
|
||||
const qc = useQueryClient()
|
||||
const { data: settings } = 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 current = settings?.['flags.display_mode']?.value ?? 'both'
|
||||
const options = [
|
||||
{ value: 'icon', label: '😀 Μόνο εικονίδιο' },
|
||||
{ value: 'text', label: 'Aa Μόνο κείμενο' },
|
||||
{ value: 'both', label: '😀 Aa Και τα δύο' },
|
||||
]
|
||||
return (
|
||||
<div style={{ padding: '14px 20px', borderTop: '1px solid #f4f4f2' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', marginBottom: 8, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Εμφάνιση σημαιών στις κάρτες τραπεζιών
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{options.map(o => (
|
||||
<button key={o.value} onClick={() => updateMut.mutate({ key: 'flags.display_mode', value: o.value })} style={{
|
||||
height: 32, padding: '0 12px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
border: `1.5px solid ${current === o.value ? '#3758c9' : '#dfe2e6'}`,
|
||||
background: current === o.value ? '#eff3ff' : 'white',
|
||||
color: current === o.value ? '#3758c9' : '#374151',
|
||||
}}>{o.label}</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' })
|
||||
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' }) },
|
||||
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 deleteMut = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/flags/defs/${id}`),
|
||||
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
function startEdit(flag) {
|
||||
setEditingId(flag.id)
|
||||
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', sort_order: flag.sort_order })
|
||||
}
|
||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
|
||||
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)} style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||
}}>+ Νέα</button>
|
||||
</div>
|
||||
<FlagDisplayModeSection />
|
||||
{showNew && (
|
||||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{flag.is_active && (
|
||||
<button onClick={() => deleteMut.mutate(flag.id)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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('Σφάλμα'),
|
||||
})
|
||||
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)} style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||
}}>+ Νέο</button>
|
||||
</div>
|
||||
{showNew && (
|
||||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<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>
|
||||
))}
|
||||
</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 { data: status, isLoading } = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const testPrint = useMutation({
|
||||
mutationFn: (id) => client.post(`/api/system/printers/test?printer_id=${id}`),
|
||||
onSuccess: (res) => {
|
||||
const d = res.data
|
||||
d.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${d.error}`)
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα επικοινωνίας'),
|
||||
})
|
||||
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">
|
||||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||||
<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 ${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' : 'text-green-700'}`}>
|
||||
{status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printers */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
|
||||
</div>
|
||||
{(!status?.printers || status.printers.length === 0) && (
|
||||
<p className="px-5 py-6 text-center text-gray-400 text-sm">Δεν βρέθηκαν εκτυπωτές.</p>
|
||||
)}
|
||||
{status?.printers?.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">{p.name}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${p.reachable ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||
{p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
<button onClick={() => testPrint.mutate(p.id)} disabled={testPrint.isPending} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">
|
||||
Test Print
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ShiftSettingsSection />
|
||||
<AutoLockSection />
|
||||
<TimezoneSection />
|
||||
<FlagDefsSection />
|
||||
<QuickTemplatesSection />
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
511
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
511
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
@@ -0,0 +1,511 @@
|
||||
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: 'Primary Background', hint: 'Card background' },
|
||||
{ key: 'badgeBg', label: 'Secondary Background', hint: 'Status badge container' },
|
||||
{ key: 'nameText', label: 'Primary Text', hint: 'Table name' },
|
||||
{ key: 'badgeText', label: 'Secondary Text', hint: 'Badge label' },
|
||||
]
|
||||
|
||||
const STATUSES = [
|
||||
{ key: 'free', label: 'Free Table' },
|
||||
{ key: 'open', label: 'Open Table (not mine)' },
|
||||
{ key: 'mine', label: 'Open Table (assigned to me)' },
|
||||
{ key: 'partially_paid', label: 'Partially Paid Table' },
|
||||
{ key: 'paid', label: 'Fully Paid Table' },
|
||||
]
|
||||
|
||||
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' }}>Pick a Colour</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 }}>Colour</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' }}>Opacity</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 }}>Quick select</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',
|
||||
}}
|
||||
>Done</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 ? '🌙 Dark Mode Preview' : '☀️ Light Mode Preview'
|
||||
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 }}>Click a swatch to edit</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' ? '☀️ Light Mode' : '🌙 Dark Mode'
|
||||
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('Failed to save colours'); 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('Reset ALL colours to defaults? This cannot be undone.')) {
|
||||
setColours(DEFAULT_COLOURS)
|
||||
saveToBackend(DEFAULT_COLOURS)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#111827', marginBottom: 4 }}>UI Personalization</h2>
|
||||
<p style={{ fontSize: 13, color: '#6b7280' }}>
|
||||
Customise how the Waiter App looks. Changes are saved to the server and sync to all devices automatically.
|
||||
{saving && <span style={{ marginLeft: 8, color: '#9ca3af' }}>Saving…</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Section: Waiter App — Table Colour Schemes */}
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827', marginBottom: 4 }}>
|
||||
Waiter App — Table Colour Schemes
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#6b7280' }}>
|
||||
Each table card has four colour slots. Click any colour swatch below to open the colour picker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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',
|
||||
}}
|
||||
>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Colour picker modal */}
|
||||
{modal && (
|
||||
<ColourPickerModal
|
||||
value={modal.value}
|
||||
slot={modal.slot}
|
||||
onClose={() => setModal(null)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import useAuthStore from '../store/authStore'
|
||||
|
||||
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 SettingsPage() {
|
||||
const user = useAuthStore(s => s.user)
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const testPrint = useMutation({
|
||||
mutationFn: (id) => client.post(`/api/system/printers/test?printer_id=${id}`),
|
||||
onSuccess: (res) => {
|
||||
const d = res.data
|
||||
d.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${d.error}`)
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα επικοινωνίας'),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h1 className="text-xl font-bold text-gray-800">Ρυθμίσεις</h1>
|
||||
|
||||
{/* System info */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||||
<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 ${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' : 'text-green-700'}`}>
|
||||
{status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printers */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
|
||||
</div>
|
||||
|
||||
{(!status?.printers || status.printers.length === 0) && (
|
||||
<p className="px-5 py-6 text-center text-gray-400 text-sm">Δεν βρέθηκαν εκτυπωτές.</p>
|
||||
)}
|
||||
|
||||
{status?.printers?.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">{p.name}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${p.reachable ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||
{p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => testPrint.mutate(p.id)}
|
||||
disabled={testPrint.isPending}
|
||||
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"
|
||||
>
|
||||
Test Print
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sysadmin-only section */}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -149,6 +149,8 @@ function ZoneModal({ waiter, groups, onClose }) {
|
||||
}
|
||||
|
||||
|
||||
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter', pin: '' }
|
||||
|
||||
export default function WaitersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [addModal, setAddModal] = useState(false)
|
||||
@@ -156,10 +158,14 @@ export default function WaitersPage() {
|
||||
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
||||
const [newPin, setNewPin] = useState('')
|
||||
const [newForm, setNewForm] = useState({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' })
|
||||
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: '' })
|
||||
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'],
|
||||
@@ -174,8 +180,23 @@ export default function WaitersPage() {
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||||
|
||||
const createWaiter = useMutation({
|
||||
mutationFn: (body) => client.post('/api/waiters/', body),
|
||||
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() },
|
||||
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 || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
@@ -231,8 +252,7 @@ export default function WaitersPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
||||
<div className="flex items-center justify-end">
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +283,7 @@ export default function WaitersPage() {
|
||||
<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 || '' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
|
||||
<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>
|
||||
)}
|
||||
@@ -279,39 +299,79 @@ export default function WaitersPage() {
|
||||
{/* 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">
|
||||
<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>
|
||||
<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">Όνομα χρήστη</label>
|
||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
|
||||
<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>
|
||||
<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>
|
||||
<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)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<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, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
|
||||
disabled={!newForm.username.trim() || newForm.pin.length < 4}
|
||||
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>
|
||||
@@ -321,8 +381,8 @@ export default function WaitersPage() {
|
||||
{/* 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">
|
||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.username}</h2>
|
||||
<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">
|
||||
@@ -344,7 +404,7 @@ export default function WaitersPage() {
|
||||
disabled={uploadAvatar.isPending}
|
||||
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
|
||||
>
|
||||
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
|
||||
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||||
</button>
|
||||
{editModal.avatar_url && (
|
||||
<button
|
||||
@@ -359,29 +419,36 @@ export default function WaitersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Όνομα χρήστη</label>
|
||||
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||
<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">Πλήρες όνομα</label>
|
||||
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Παρατσούκλι (nickname)</label>
|
||||
<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 })}
|
||||
disabled={updateWaiter.isPending || !editForm.username.trim()}
|
||||
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>
|
||||
356
manager_dashboard/src/pages/TablesConfigTab.jsx
Normal file
356
manager_dashboard/src/pages/TablesConfigTab.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
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 '../components/ConfirmModal'
|
||||
|
||||
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 { 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)
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap items-center justify-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer mr-auto">
|
||||
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" />
|
||||
Εμφάνιση ανενεργών
|
||||
</label>
|
||||
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέα ζώνη</button>
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary text-sm">+ Νέο τραπέζι</button>
|
||||
</div>
|
||||
|
||||
{/* Zone tabs */}
|
||||
<div className="flex gap-1 flex-wrap border-b border-gray-200 pb-0">
|
||||
{[
|
||||
{ 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 }] : []),
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white border border-b-white border-gray-200 -mb-px text-primary-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tab.color && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||||
{tab.label}
|
||||
<span className="ml-0.5 text-xs text-gray-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 header (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">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">{g.name}</span>
|
||||
{g.prefix && <span className="ml-2 text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-mono">{g.prefix}</span>}
|
||||
</div>
|
||||
<button onClick={() => setGroupModal(g)} className="text-xs text-gray-400 hover:text-gray-600 underline">Επεξεργασία ζώνης</button>
|
||||
<button onClick={() => setBatchModal(g)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Tables list */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
{visibleTables.length === 0 && (
|
||||
<p className="px-4 py-8 text-sm text-gray-400 text-center">
|
||||
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
|
||||
</p>
|
||||
)}
|
||||
{visibleTables.map((t, idx) => (
|
||||
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-50 bg-gray-50' : ''}`}>
|
||||
<span className="text-xs text-gray-400 font-mono w-6 text-right">{idx + 1}</span>
|
||||
<p className="flex-1 font-medium text-gray-800">{t.label || `Τραπέζι ${t.number}`}</p>
|
||||
{t.group && (
|
||||
<span className="text-xs bg-gray-100 text-gray-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 onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8">Επεξεργασία</button>
|
||||
{t.is_active
|
||||
? <button
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ id: t.id, hard: false })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Απενεργ.</button>
|
||||
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
|
||||
}
|
||||
<button
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ id: t.id, hard: true })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-8 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Διαγραφή</button>
|
||||
</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}
|
||||
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.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'}
|
||||
message={confirmDelete.hard
|
||||
? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
|
||||
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'}
|
||||
confirmLabel={confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
|
||||
confirmClass="btn-danger"
|
||||
onConfirm={() => deleteTable.mutate(confirmDelete)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableModal({ title, initial, groups, onSave, onClose }) {
|
||||
const [form, setForm] = useState(initial)
|
||||
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-TBL-1 ή Βεράντα 3"
|
||||
value={form.label}
|
||||
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό για αυτόματη αρίθμηση.</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)} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BatchModal({ group, onSave, onClose }) {
|
||||
const [count, setCount] = useState(5)
|
||||
const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
|
||||
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-TBL- → BS-TBL-1, BS-TBL-2…"
|
||||
value={prefix}
|
||||
onChange={e => setPrefix(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<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: prefix })}
|
||||
disabled={count < 1 || !prefix.trim()}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
Δημιουργία {count > 0 && prefix.trim() ? `(${prefix.trim()}1 … ${prefix.trim()}${count})` : ''}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,30 @@ import { create } from 'zustand'
|
||||
|
||||
const useAuthStore = create((set) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('token') || null,
|
||||
token: localStorage.getItem('manager_token') || null,
|
||||
savedUsername: localStorage.getItem('manager_username') || null,
|
||||
locked: false,
|
||||
|
||||
login(user, token) {
|
||||
localStorage.setItem('token', token)
|
||||
set({ user, token })
|
||||
localStorage.setItem('manager_token', token)
|
||||
localStorage.setItem('manager_username', user.username)
|
||||
set({ user, token, savedUsername: user.username, locked: false })
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('token')
|
||||
set({ user: null, token: null })
|
||||
localStorage.removeItem('manager_token')
|
||||
localStorage.removeItem('manager_username')
|
||||
localStorage.removeItem('manager_lock_timeout')
|
||||
set({ user: null, token: null, savedUsername: null, locked: false })
|
||||
},
|
||||
|
||||
lock() {
|
||||
set({ locked: true })
|
||||
},
|
||||
|
||||
unlock(user, token) {
|
||||
localStorage.setItem('manager_token', token)
|
||||
set({ user, token, locked: false })
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user