Phase 3: scaffold Manager Dashboard — all pages, layout, routing
Includes: LoginPage (PIN pad), DashboardPage (30s polling table grid), OrderDetailPage (full actions), ProductsPage (CRUD + printer zone), WaitersPage (block/reset PIN/delete), TablesPage, ReportsPage (shift summary + order history + CSV export), SettingsPage (printers + test print + sysadmin lock/unlock). TailwindCSS, React Query, react-hot-toast. Docker Compose service on port 5174.
This commit is contained in:
@@ -26,3 +26,17 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
manager_dashboard:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./manager_dashboard:/app
|
||||||
|
ports:
|
||||||
|
- "5174:5174"
|
||||||
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
|
env_file:
|
||||||
|
- ./manager_dashboard/.env
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
12
manager_dashboard/index.html
Normal file
12
manager_dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="el">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>POS Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
manager_dashboard/package.json
Normal file
29
manager_dashboard/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "manager-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
manager_dashboard/postcss.config.js
Normal file
3
manager_dashboard/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
}
|
||||||
36
manager_dashboard/src/App.jsx
Normal file
36
manager_dashboard/src/App.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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 TablesPage from './pages/TablesPage'
|
||||||
|
import ReportsPage from './pages/ReportsPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
|
||||||
|
function RequireAuth({ children }) {
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
return token ? children : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<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 path="tables" element={<TablesPage />} />
|
||||||
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
manager_dashboard/src/api/client.js
Normal file
24
manager_dashboard/src/api/client.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
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')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default client
|
||||||
14
manager_dashboard/src/components/ConfirmModal.jsx
Normal file
14
manager_dashboard/src/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export default function ConfirmModal({ title, message, confirmLabel = 'Επιβεβαίωση', confirmClass = 'btn-danger', onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
|
||||||
|
{message && <p className="text-gray-600 text-sm">{message}</p>}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button onClick={onCancel} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
|
<button onClick={onConfirm} className={`flex-1 btn ${confirmClass}`}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
manager_dashboard/src/components/Sidebar.jsx
Normal file
47
manager_dashboard/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
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: 'Ρυθμίσεις' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||||
|
{/* Logo / collapse toggle */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
|
||||||
|
{!collapsed && <span className="font-bold text-lg tracking-wide">POS</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
{collapsed ? '→' : '←'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||||
|
{NAV.map(({ to, icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-3 rounded-lg font-medium transition-colors min-h-[44px] ` +
|
||||||
|
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-xl leading-none shrink-0">{icon}</span>
|
||||||
|
{!collapsed && <span className="text-sm">{label}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
manager_dashboard/src/components/StatusBadge.jsx
Normal file
18
manager_dashboard/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const MAP = {
|
||||||
|
free: { label: 'Ελεύθερο', cls: 'bg-gray-100 text-gray-600' },
|
||||||
|
open: { label: 'Ανοιχτό', cls: 'bg-green-100 text-green-700' },
|
||||||
|
partially_paid: { label: 'Μερική πληρωμή', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
paid: { label: 'Πληρώθηκε', cls: 'bg-blue-100 text-blue-700' },
|
||||||
|
closed: { label: 'Κλειστό', cls: 'bg-gray-200 text-gray-500' },
|
||||||
|
cancelled: { label: 'Ακυρώθηκε', cls: 'bg-red-100 text-red-600' },
|
||||||
|
active: { label: 'Ενεργό', cls: 'bg-green-100 text-green-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }) {
|
||||||
|
const { label, cls } = MAP[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' }
|
||||||
|
return (
|
||||||
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
manager_dashboard/src/index.css
Normal file
36
manager_dashboard/src/index.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 text-base antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2.5 rounded-xl font-semibold text-sm transition-colors min-h-[44px] disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-700 hover:bg-primary-800 text-white;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-100 hover:bg-gray-200 text-gray-700;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply bg-transparent hover:bg-gray-100 text-gray-600;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-2xl shadow-sm border border-gray-100;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full border border-gray-300 rounded-xl px-4 py-2.5 text-base focus:outline-none focus:ring-2 focus:ring-primary-600 disabled:bg-gray-50;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
49
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { useState, useEffect } 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())
|
||||||
|
|
||||||
|
// Fetch user profile once on mount if token exists but user isn't loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && !user) {
|
||||||
|
client.get('/auth/me').then(r => login(r.data, token)).catch(() => logout())
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setClock(new Date()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<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">
|
||||||
|
<span className="text-sm text-gray-500">{user?.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-red-600 hover:text-red-800 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Αποσύνδεση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
manager_dashboard/src/main.jsx
Normal file
21
manager_dashboard/src/main.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: 1, staleTime: 10_000 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
117
manager_dashboard/src/pages/DashboardPage.jsx
Normal file
117
manager_dashboard/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import client from '../api/client'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
|
||||||
|
const FILTERS = ['all', 'open', 'partially_paid', 'free']
|
||||||
|
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
||||||
|
|
||||||
|
function elapsed(openedAt) {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||||
|
if (diff < 60) return `${diff}λ`
|
||||||
|
return `${Math.floor(diff / 60)}ω ${diff % 60}λ`
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderTotal(items = []) {
|
||||||
|
return items
|
||||||
|
.filter(i => i.status !== 'cancelled')
|
||||||
|
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||||
|
queryKey: ['orders-active'],
|
||||||
|
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: waiters = [] } = useQuery({
|
||||||
|
queryKey: ['waiters'],
|
||||||
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||||
|
|
||||||
|
// Build enriched table list
|
||||||
|
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 className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{filtered.map(({ table, order, tableStatus }) => (
|
||||||
|
<button
|
||||||
|
key={table.id}
|
||||||
|
onClick={() => order && navigate(`/orders/${order.id}`)}
|
||||||
|
className={`card p-4 text-left transition-shadow hover:shadow-md ${!order ? 'cursor-default' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<span className="text-2xl font-extrabold text-gray-800">
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={tableStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order ? (
|
||||||
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
|
<p className="font-semibold text-gray-800">€{orderTotal(order.items)}</p>
|
||||||
|
<p>⏱ {elapsed(order.opened_at)}</p>
|
||||||
|
{order.waiters.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{order.waiters.map(w => waiterMap[w.waiter_id] || `#${w.waiter_id}`).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 mt-1">—</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
manager_dashboard/src/pages/LoginPage.jsx
Normal file
108
manager_dashboard/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [pin, setPin] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { login } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
function pressDigit(d) {
|
||||||
|
if (d === '⌫') { setPin(p => p.slice(0, -1)); return }
|
||||||
|
if (d === '') return
|
||||||
|
if (pin.length >= 6) return
|
||||||
|
setPin(p => p + d)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!username.trim() || pin.length < 4) return
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await client.post('/api/auth/login', { username: username.trim(), pin })
|
||||||
|
const role = data.user.role
|
||||||
|
if (role !== 'manager' && role !== 'sysadmin') {
|
||||||
|
setError('Δεν έχεις δικαιώματα διαχειριστή.')
|
||||||
|
setPin('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
login(data.user, data.access_token)
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')
|
||||||
|
setPin('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-800 text-center mb-1">POS Manager</h1>
|
||||||
|
<p className="text-center text-gray-500 text-sm mb-6">Πίνακας Διαχείρισης</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<input
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||||
|
placeholder="Όνομα χρήστη"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* PIN display */}
|
||||||
|
<div className="flex justify-center gap-3 py-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-4 h-4 rounded-full border-2 transition-colors ${
|
||||||
|
i < pin.length ? 'bg-primary-700 border-primary-700' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PIN pad */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{DIGITS.map((d, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type={d === '' ? 'button' : 'button'}
|
||||||
|
onClick={() => pressDigit(d)}
|
||||||
|
disabled={d === ''}
|
||||||
|
className={`h-14 rounded-xl text-xl font-semibold transition-colors ${
|
||||||
|
d === ''
|
||||||
|
? 'invisible'
|
||||||
|
: d === '⌫'
|
||||||
|
? 'bg-gray-100 hover:bg-gray-200 text-gray-600'
|
||||||
|
: 'bg-gray-100 hover:bg-primary-100 active:bg-primary-200 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-center text-red-600 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim() || pin.length < 4}
|
||||||
|
className="w-full bg-primary-700 hover:bg-primary-800 disabled:opacity-40 text-white font-semibold py-3 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Σύνδεση…' : 'Σύνδεση'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
manager_dashboard/src/pages/OrderDetailPage.jsx
Normal file
239
manager_dashboard/src/pages/OrderDetailPage.jsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
|
|
||||||
|
function itemTotal(item) {
|
||||||
|
return (item.unit_price * item.quantity).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dt) {
|
||||||
|
if (!dt) return '—'
|
||||||
|
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
|
||||||
|
const { orderId: paramOrderId } = useParams()
|
||||||
|
const orderId = propOrderId ?? paramOrderId
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||||
|
|
||||||
|
const { data: order, isLoading } = useQuery({
|
||||||
|
queryKey: ['order', orderId],
|
||||||
|
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
|
||||||
|
enabled: !!orderId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: waiters = [] } = useQuery({
|
||||||
|
queryKey: ['waiters'],
|
||||||
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||||
|
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelItem = useMutation({
|
||||||
|
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
|
||||||
|
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα ακύρωσης αντικειμένου'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelOrder = useMutation({
|
||||||
|
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
||||||
|
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/dashboard') },
|
||||||
|
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeOrder = useMutation({
|
||||||
|
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
|
||||||
|
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/dashboard') },
|
||||||
|
onError: () => toast.error('Σφάλμα κλεισίματος'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignWaiter = useMutation({
|
||||||
|
mutationFn: (waiter_id) => client.put(`/api/orders/${orderId}/assign-waiter`, { waiter_id }),
|
||||||
|
onSuccess: () => { toast.success('Σερβιτόρος προστέθηκε'); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeWaiter = useMutation({
|
||||||
|
mutationFn: (wid) => client.delete(`/api/orders/${orderId}/waiters/${wid}`),
|
||||||
|
onSuccess: () => { toast.success('Σερβιτόρος αφαιρέθηκε'); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payItems = useMutation({
|
||||||
|
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
|
||||||
|
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα πληρωμής'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!confirmAction) return
|
||||||
|
const { type, payload } = confirmAction
|
||||||
|
if (type === 'cancelItem') cancelItem.mutate(payload)
|
||||||
|
if (type === 'cancelOrder') cancelOrder.mutate()
|
||||||
|
if (type === 'closeOrder') closeOrder.mutate()
|
||||||
|
setConfirmAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||||
|
if (!order) return <div className="text-center py-16 text-gray-400">Παραγγελία δεν βρέθηκε.</div>
|
||||||
|
|
||||||
|
const activeItems = order.items.filter(i => i.status === 'active')
|
||||||
|
const total = order.items
|
||||||
|
.filter(i => i.status !== 'cancelled')
|
||||||
|
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
|
||||||
|
const isOpen = ['open', 'partially_paid'].includes(order.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
{!propOrderId && (
|
||||||
|
<button onClick={() => navigate(-1)} className="btn btn-ghost text-sm">← Πίσω</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="card p-5 flex flex-wrap gap-4 items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
|
||||||
|
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
<span className="text-lg font-bold text-gray-800">€{total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waiters */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{order.waiters.map(w => (
|
||||||
|
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||||
|
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||||||
|
{isOpen && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeWaiter.mutate(w.waiter_id)}
|
||||||
|
className="text-gray-400 hover:text-red-500 text-xs leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isOpen && !readOnly && (
|
||||||
|
<select
|
||||||
|
className="text-sm border border-gray-300 rounded-full px-3 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>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
|
||||||
|
</div>
|
||||||
|
{order.items.length === 0 && (
|
||||||
|
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||||
|
)}
|
||||||
|
{order.items.map(item => (
|
||||||
|
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||||
|
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||||
|
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||||
|
{isOpen && !readOnly && item.status === 'active' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => payItems.mutate([item.id])}
|
||||||
|
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||||
|
>
|
||||||
|
Πληρωμή
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||||
|
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||||
|
>
|
||||||
|
Ακύρωση
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{isOpen && !readOnly && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{activeItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Πληρωμή όλων
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Κλείσιμο παραγγελίας
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||||
|
className="btn btn-danger"
|
||||||
|
>
|
||||||
|
Ακύρωση παραγγελίας
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmAction && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={
|
||||||
|
confirmAction.type === 'cancelItem' ? 'Ακύρωση αντικειμένου;' :
|
||||||
|
confirmAction.type === 'cancelOrder' ? 'Ακύρωση παραγγελίας;' :
|
||||||
|
'Κλείσιμο παραγγελίας;'
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
confirmAction.type === 'cancelOrder'
|
||||||
|
? 'Η παραγγελία θα ακυρωθεί οριστικά.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel={confirmAction.type === 'closeOrder' ? 'Κλείσιμο' : 'Ακύρωση'}
|
||||||
|
confirmClass={confirmAction.type === 'closeOrder' ? 'btn-primary' : 'btn-danger'}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
315
manager_dashboard/src/pages/ProductsPage.jsx
Normal file
315
manager_dashboard/src/pages/ProductsPage.jsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
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 EMPTY_PRODUCT = { name: '', category_id: '', base_price: '', is_available: true, printer_zone_id: '', options: [], ingredients: [] }
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [selectedCat, setSelectedCat] = useState(null)
|
||||||
|
const [editProduct, setEditProduct] = useState(null) // null | 'new' | product object
|
||||||
|
const [editCat, setEditCat] = useState(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(null) // { type: 'product'|'category', id }
|
||||||
|
|
||||||
|
const { data: categories = [] } = useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: () => client.get('/api/products/categories').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: allProducts = [] } = useQuery({
|
||||||
|
queryKey: ['products-all'],
|
||||||
|
queryFn: () => client.get('/api/products/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: printers = [] } = useQuery({
|
||||||
|
queryKey: ['printers'],
|
||||||
|
queryFn: () => client.get('/api/system/status').then(r => r.data.printers ?? []),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const products = selectedCat
|
||||||
|
? allProducts.filter(p => p.category_id === selectedCat)
|
||||||
|
: allProducts
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['products-all'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['categories'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCat = useMutation({
|
||||||
|
mutationFn: (body) =>
|
||||||
|
editCat?.id
|
||||||
|
? client.put(`/api/products/categories/${editCat.id}`, body)
|
||||||
|
: client.post('/api/products/categories', body),
|
||||||
|
onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteCat = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/products/categories/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Διαγράφηκε'); setConfirmDelete(null); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveProduct = useMutation({
|
||||||
|
mutationFn: (body) =>
|
||||||
|
editProduct?.id
|
||||||
|
? client.put(`/api/products/${editProduct.id}`, body)
|
||||||
|
: client.post('/api/products/', body),
|
||||||
|
onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAvail = useMutation({
|
||||||
|
mutationFn: ({ id, is_available }) => client.put(`/api/products/${id}`, { is_available }),
|
||||||
|
onSuccess: () => { invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteProduct = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/products/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDelete(null); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleConfirmDelete() {
|
||||||
|
if (!confirmDelete) return
|
||||||
|
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
|
||||||
|
if (confirmDelete.type === 'product') deleteProduct.mutate(confirmDelete.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-6 h-full">
|
||||||
|
{/* Left: Categories */}
|
||||||
|
<aside className="w-56 shrink-0 space-y-2">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-semibold text-gray-700">Κατηγορίες</h2>
|
||||||
|
<button onClick={() => setEditCat({})} className="btn btn-primary text-xs px-2 py-1 min-h-0 h-8">+</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCat(null)}
|
||||||
|
className={`w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${!selectedCat ? 'bg-primary-700 text-white' : 'hover:bg-gray-100 text-gray-700'}`}
|
||||||
|
>
|
||||||
|
Όλα
|
||||||
|
</button>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div key={cat.id} className={`flex items-center gap-1 rounded-xl ${selectedCat === cat.id ? 'bg-primary-700 text-white' : 'hover:bg-gray-100'}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCat(cat.id)}
|
||||||
|
className="flex-1 text-left px-3 py-2.5 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditCat(cat)} className="p-1.5 rounded-lg hover:bg-black/10 text-xs">✏️</button>
|
||||||
|
<button onClick={() => setConfirmDelete({ type: 'category', id: cat.id })} className="p-1.5 rounded-lg hover:bg-red-100 text-xs mr-1">🗑</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Right: Products */}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-700">
|
||||||
|
Προϊόντα {selectedCat ? `— ${categories.find(c => c.id === selectedCat)?.name}` : ''}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setEditProduct({ ...EMPTY_PRODUCT })} className="btn btn-primary">+ Νέο προϊόν</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 && (
|
||||||
|
<p className="text-center text-gray-400 py-16">Δεν υπάρχουν προϊόντα.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{products.map(p => (
|
||||||
|
<div key={p.id} className="card p-4 flex items-center gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-gray-800">{p.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{categories.find(c => c.id === p.category_id)?.name ?? '—'} ·
|
||||||
|
€{p.base_price.toFixed(2)}
|
||||||
|
{p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.is_available}
|
||||||
|
onChange={e => toggleAvail.mutate({ id: p.id, is_available: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-primary-700"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600">Διαθέσιμο</span>
|
||||||
|
</label>
|
||||||
|
<button onClick={() => setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
|
||||||
|
<button onClick={() => setConfirmDelete({ type: 'product', id: p.id })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category form modal */}
|
||||||
|
{editCat !== null && (
|
||||||
|
<CategoryFormModal
|
||||||
|
cat={editCat}
|
||||||
|
onSave={(name, sort_order) => saveCat.mutate({ name, sort_order: Number(sort_order) })}
|
||||||
|
onClose={() => setEditCat(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product form panel */}
|
||||||
|
{editProduct !== null && (
|
||||||
|
<ProductFormPanel
|
||||||
|
product={editProduct}
|
||||||
|
categories={categories}
|
||||||
|
printers={printers}
|
||||||
|
onSave={(body) => saveProduct.mutate(body)}
|
||||||
|
onClose={() => setEditProduct(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Επιβεβαίωση διαγραφής"
|
||||||
|
message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί.'}
|
||||||
|
confirmLabel="Διαγραφή"
|
||||||
|
confirmClass="btn-danger"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryFormModal({ cat, onSave, onClose }) {
|
||||||
|
const [name, setName] = useState(cat.name || '')
|
||||||
|
const [sort, setSort] = useState(cat.sort_order ?? 0)
|
||||||
|
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-xs p-6 space-y-4">
|
||||||
|
<h2 className="font-bold text-gray-800">{cat.id ? 'Επεξεργασία κατηγορίας' : 'Νέα κατηγορία'}</h2>
|
||||||
|
<div>
|
||||||
|
<label className="label">Όνομα</label>
|
||||||
|
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Σειρά ταξινόμησης</label>
|
||||||
|
<input className="input" type="number" value={sort} onChange={e => setSort(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
|
<button onClick={() => onSave(name, sort)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: product.name || '',
|
||||||
|
category_id: product.category_id || '',
|
||||||
|
base_price: product.base_price || '',
|
||||||
|
is_available: product.is_available ?? true,
|
||||||
|
printer_zone_id: product.printer_zone_id || '',
|
||||||
|
options: product.options?.map(o => ({ name: o.name, extra_cost: o.extra_cost })) ?? [],
|
||||||
|
ingredients: product.ingredients?.map(i => ({ name: i.name })) ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
function setField(k, v) { setForm(f => ({ ...f, [k]: v })) }
|
||||||
|
|
||||||
|
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0 }] })) }
|
||||||
|
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
|
||||||
|
function setOption(i, k, v) {
|
||||||
|
setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '' }] })) }
|
||||||
|
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
|
||||||
|
function setIngredient(i, v) {
|
||||||
|
setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { name: v } : ing) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const body = {
|
||||||
|
...form,
|
||||||
|
category_id: form.category_id ? Number(form.category_id) : null,
|
||||||
|
base_price: parseFloat(form.base_price),
|
||||||
|
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
|
||||||
|
}
|
||||||
|
onSave(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex justify-end z-50">
|
||||||
|
<div className="bg-white w-full max-w-md h-full overflow-y-auto shadow-xl p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-bold text-gray-800 text-lg">{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}</h2>
|
||||||
|
<button onClick={onClose} className="btn btn-ghost">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Όνομα *</label>
|
||||||
|
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Κατηγορία</label>
|
||||||
|
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
|
||||||
|
<option value="">— Χωρίς κατηγορία —</option>
|
||||||
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Τιμή βάσης (€) *</label>
|
||||||
|
<input className="input" type="number" step="0.01" min="0" value={form.base_price} onChange={e => setField('base_price', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Ζώνη εκτυπωτή</label>
|
||||||
|
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
|
||||||
|
<option value="">— Χωρίς εκτυπωτή —</option>
|
||||||
|
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.is_available} onChange={e => setField('is_available', e.target.checked)} className="w-4 h-4 accent-primary-700" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Διαθέσιμο</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="label mb-0">Επιλογές</label>
|
||||||
|
<button onClick={addOption} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Επιλογή</button>
|
||||||
|
</div>
|
||||||
|
{form.options.map((opt, i) => (
|
||||||
|
<div key={i} className="flex gap-2 mb-2">
|
||||||
|
<input className="input flex-1" placeholder="Όνομα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
|
||||||
|
<input className="input w-24" type="number" step="0.01" placeholder="+€" value={opt.extra_cost} onChange={e => setOption(i, 'extra_cost', parseFloat(e.target.value) || 0)} />
|
||||||
|
<button onClick={() => removeOption(i)} className="btn btn-danger px-2 min-h-0 h-10">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredients */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="label mb-0">Υλικά</label>
|
||||||
|
<button onClick={addIngredient} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Υλικό</button>
|
||||||
|
</div>
|
||||||
|
{form.ingredients.map((ing, i) => (
|
||||||
|
<div key={i} className="flex gap-2 mb-2">
|
||||||
|
<input className="input flex-1" placeholder="Υλικό" value={ing.name} onChange={e => setIngredient(i, e.target.value)} />
|
||||||
|
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-2 min-h-0 h-10">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
|
<button onClick={submit} disabled={!form.name.trim() || !form.base_price} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
209
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import client from '../api/client'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
|
||||||
|
function today() {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvDownload(rows, filename) {
|
||||||
|
const header = Object.keys(rows[0]).join(',')
|
||||||
|
const body = rows.map(r => Object.values(r).join(',')).join('\n')
|
||||||
|
const blob = new Blob([header + '\n' + body], { type: 'text/csv' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const [tab, setTab] = useState('shift')
|
||||||
|
const [shiftDate, setShiftDate] = useState(today())
|
||||||
|
const [historyFilters, setHistoryFilters] = useState({ from: today(), to: today(), status: '' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Αναφορές</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setTab('shift')} className={`btn ${tab === 'shift' ? 'btn-primary' : 'btn-secondary'}`}>Σύνοψη βάρδιας</button>
|
||||||
|
<button onClick={() => setTab('history')} className={`btn ${tab === 'history' ? 'btn-primary' : 'btn-secondary'}`}>Ιστορικό παραγγελιών</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'shift' && <ShiftTab date={shiftDate} setDate={setShiftDate} />}
|
||||||
|
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftTab({ date, setDate }) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['report-shift', date],
|
||||||
|
queryFn: () => client.get(`/api/reports/shift?date=${date}`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = data
|
||||||
|
? Object.entries(data.waiters).map(([name, s]) => ({
|
||||||
|
Σερβιτόρος: name,
|
||||||
|
Παραγγελίες: s.orders,
|
||||||
|
'Αντικείμενα': s.items,
|
||||||
|
'Σύνολο (€)': s.total.toFixed(2),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const grandTotal = rows.reduce((s, r) => s + parseFloat(r['Σύνολο (€)']), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Ημερομηνία</label>
|
||||||
|
<input type="date" className="input w-44" value={date} onChange={e => setDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => csvDownload(rows, `shift_${date}.csv`)}
|
||||||
|
className="btn btn-secondary self-end"
|
||||||
|
>
|
||||||
|
Εξαγωγή 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-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
{Object.keys(rows[0]).map(h => (
|
||||||
|
<th key={h} className="text-left px-4 py-3 font-semibold text-gray-600">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50">
|
||||||
|
{Object.values(r).map((v, j) => (
|
||||||
|
<td key={j} className="px-4 py-3 text-gray-800">{v}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||||||
|
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Παραγγελίες'], 0)}</td>
|
||||||
|
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Αντικείμενα'], 0)}</td>
|
||||||
|
<td className="px-4 py-3 font-bold text-primary-700">€{grandTotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryTab({ filters, setFilters }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ from: filters.from, to: filters.to + 'T23:59:59', page })
|
||||||
|
if (filters.status) params.set('status', filters.status)
|
||||||
|
|
||||||
|
const { data: orders = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['order-history', filters, page],
|
||||||
|
queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Από</label>
|
||||||
|
<input type="date" className="input w-40" value={filters.from} onChange={e => setF('from', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Έως</label>
|
||||||
|
<input type="date" className="input w-40" value={filters.to} onChange={e => setF('to', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Κατάσταση</label>
|
||||||
|
<select className="input w-44" value={filters.status} onChange={e => setF('status', e.target.value)}>
|
||||||
|
<option value="">Όλες</option>
|
||||||
|
<option value="open">Ανοιχτές</option>
|
||||||
|
<option value="partially_paid">Μερική πληρωμή</option>
|
||||||
|
<option value="closed">Κλειστές</option>
|
||||||
|
<option value="cancelled">Ακυρωμένες</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||||
|
|
||||||
|
{!isLoading && orders.length === 0 && (
|
||||||
|
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orders.length > 0 && (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<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-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 />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{orders.map(o => {
|
||||||
|
const total = o.items
|
||||||
|
.filter(i => i.status !== 'cancelled')
|
||||||
|
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
return (
|
||||||
|
<tr key={o.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-gray-500">{o.id}</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-800">{o.table_id}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{new Date(o.opened_at).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={o.status} /></td>
|
||||||
|
<td className="px-4 py-3 text-right font-semibold text-gray-800">€{total.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/orders/${o.id}`)}
|
||||||
|
className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7"
|
||||||
|
>
|
||||||
|
Προβολή
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">← Προηγ.</button>
|
||||||
|
<span className="text-sm text-gray-500">Σελίδα {page}</span>
|
||||||
|
<button onClick={() => setPage(p => p + 1)} disabled={orders.length < 50} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επόμ. →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
manager_dashboard/src/pages/SettingsPage.jsx
Normal file
113
manager_dashboard/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
manager_dashboard/src/pages/TablesPage.jsx
Normal file
128
manager_dashboard/src/pages/TablesPage.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export default function TablesPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [addModal, setAddModal] = useState(false)
|
||||||
|
const [editModal, setEditModal] = useState(null)
|
||||||
|
const [confirmDeactivate, setConfirmDeactivate] = useState(null)
|
||||||
|
const [form, setForm] = useState({ number: '', label: '' })
|
||||||
|
|
||||||
|
const { data: tables = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['tables'],
|
||||||
|
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['tables'] })
|
||||||
|
|
||||||
|
const createTable = useMutation({
|
||||||
|
mutationFn: (body) => client.post('/api/tables/', body),
|
||||||
|
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); setForm({ number: '', label: '' }); 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 deactivateTable = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/tables/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDeactivate(null); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1>
|
||||||
|
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
{tables.length === 0 && (
|
||||||
|
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν τραπέζια.</p>
|
||||||
|
)}
|
||||||
|
{tables.map(t => (
|
||||||
|
<div key={t.id} className="flex items-center gap-4 px-4 py-3">
|
||||||
|
<span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span>
|
||||||
|
<p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditModal(t); }}
|
||||||
|
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Επεξεργασία
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeactivate(t.id)}
|
||||||
|
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Απενεργοποίηση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add table modal */}
|
||||||
|
{addModal && (
|
||||||
|
<TableModal
|
||||||
|
title="Νέο τραπέζι"
|
||||||
|
form={form}
|
||||||
|
setForm={setForm}
|
||||||
|
onSave={() => createTable.mutate({ number: Number(form.number), label: form.label || null })}
|
||||||
|
onClose={() => setAddModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit table modal */}
|
||||||
|
{editModal && (
|
||||||
|
<TableModal
|
||||||
|
title="Επεξεργασία τραπεζιού"
|
||||||
|
form={{ number: editModal.number, label: editModal.label || '' }}
|
||||||
|
setForm={(f) => setEditModal(t => ({ ...t, ...f }))}
|
||||||
|
onSave={() => updateTable.mutate({ id: editModal.id, number: Number(editModal.number), label: editModal.label || null })}
|
||||||
|
onClose={() => setEditModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDeactivate !== null && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Απενεργοποίηση τραπεζιού;"
|
||||||
|
message="Το τραπέζι δεν θα εμφανίζεται πλέον."
|
||||||
|
confirmLabel="Απενεργοποίηση"
|
||||||
|
confirmClass="btn-danger"
|
||||||
|
onConfirm={() => deactivateTable.mutate(confirmDeactivate)}
|
||||||
|
onCancel={() => setConfirmDeactivate(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableModal({ title, form, setForm, onSave, onClose }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
|
||||||
|
<h2 className="font-bold text-gray-800">{title}</h2>
|
||||||
|
<div>
|
||||||
|
<label className="label">Αριθμός τραπεζιού *</label>
|
||||||
|
<input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Ετικέτα (προαιρετικό)</label>
|
||||||
|
<input className="input" placeholder="π.χ. Βεράντα 1" value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
|
<button onClick={onSave} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
manager_dashboard/src/pages/WaitersPage.jsx
Normal file
169
manager_dashboard/src/pages/WaitersPage.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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 DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||||
|
|
||||||
|
function PinInput({ value, onChange }) {
|
||||||
|
function press(d) {
|
||||||
|
if (d === '⌫') { onChange(value.slice(0, -1)); return }
|
||||||
|
if (d === '' || value.length >= 6) return
|
||||||
|
onChange(value + d)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className={`w-3 h-3 rounded-full border-2 ${i < value.length ? 'bg-primary-700 border-primary-700' : 'border-gray-300'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{DIGITS.map((d, i) => (
|
||||||
|
<button key={i} type="button" onClick={() => press(d)} disabled={d === ''} className={`h-11 rounded-xl text-lg font-semibold transition-colors ${d === '' ? 'invisible' : d === '⌫' ? 'bg-gray-100 hover:bg-gray-200 text-gray-600' : 'bg-gray-100 hover:bg-primary-100 text-gray-800'}`}>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaitersPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [addModal, setAddModal] = useState(false)
|
||||||
|
const [pinModal, setPinModal] = useState(null) // waiter id
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
||||||
|
const [newPin, setNewPin] = useState('')
|
||||||
|
const [newForm, setNewForm] = useState({ username: '', pin: '', role: 'waiter' })
|
||||||
|
|
||||||
|
const { data: waiters = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['waiters'],
|
||||||
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||||||
|
|
||||||
|
const createWaiter = useMutation({
|
||||||
|
mutationFn: (body) => client.post('/api/waiters/', body),
|
||||||
|
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', pin: '', role: 'waiter' }); invalidate() },
|
||||||
|
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleBlock = useMutation({
|
||||||
|
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||||||
|
onSuccess: () => { invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPin = useMutation({
|
||||||
|
mutationFn: ({ id, pin }) => client.put(`/api/waiters/${id}/reset-pin?pin=${encodeURIComponent(pin)}`),
|
||||||
|
onSuccess: () => { toast.success('PIN ανανεώθηκε'); setPinModal(null); setNewPin('') },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteWaiter = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/waiters/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Διαγράφηκε'); setConfirmDelete(null); invalidate() },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
||||||
|
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
{waiters.length === 0 && (
|
||||||
|
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν σερβιτόροι.</p>
|
||||||
|
)}
|
||||||
|
{waiters.map(w => (
|
||||||
|
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-gray-800">{w.username}</p>
|
||||||
|
<p className="text-xs text-gray-500">{w.role}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||||
|
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
|
||||||
|
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
|
||||||
|
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmDelete(w.id)} className="btn btn-ghost text-sm px-2 py-1.5 min-h-0 h-9 text-red-500 hover:bg-red-50">🗑</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add waiter modal */}
|
||||||
|
{addModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||||
|
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
|
||||||
|
<div>
|
||||||
|
<label className="label">Όνομα χρήστη</label>
|
||||||
|
<input className="input" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Ρόλος</label>
|
||||||
|
<select className="input" value={newForm.role} onChange={e => setNewForm(f => ({ ...f, role: e.target.value }))}>
|
||||||
|
<option value="waiter">Σερβιτόρος</option>
|
||||||
|
<option value="manager">Διαχειριστής</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-2">PIN</label>
|
||||||
|
<PinInput value={newForm.pin} onChange={pin => setNewForm(f => ({ ...f, pin }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createWaiter.mutate({ username: newForm.username, pin: newForm.pin, role: newForm.role, is_active: true })}
|
||||||
|
disabled={!newForm.username.trim() || newForm.pin.length < 4}
|
||||||
|
className="flex-1 btn btn-primary"
|
||||||
|
>
|
||||||
|
Δημιουργία
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset PIN modal */}
|
||||||
|
{pinModal !== null && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
|
||||||
|
<h2 className="font-bold text-gray-800">Reset PIN — {waiters.find(w => w.id === pinModal)?.username}</h2>
|
||||||
|
<PinInput value={newPin} onChange={setNewPin} />
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })}
|
||||||
|
disabled={newPin.length < 4}
|
||||||
|
className="flex-1 btn btn-primary"
|
||||||
|
>
|
||||||
|
Αποθήκευση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDelete !== null && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Διαγραφή σερβιτόρου;"
|
||||||
|
message="Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
||||||
|
confirmLabel="Διαγραφή"
|
||||||
|
confirmClass="btn-danger"
|
||||||
|
onConfirm={() => deleteWaiter.mutate(confirmDelete)}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
manager_dashboard/src/store/authStore.js
Normal file
18
manager_dashboard/src/store/authStore.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const useAuthStore = create((set) => ({
|
||||||
|
user: null,
|
||||||
|
token: localStorage.getItem('token') || null,
|
||||||
|
|
||||||
|
login(user, token) {
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
set({ user, token })
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
set({ user: null, token: null })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useAuthStore
|
||||||
23
manager_dashboard/tailwind.config.js
Normal file
23
manager_dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#0f766e',
|
||||||
|
50: '#f0fdfa',
|
||||||
|
100: '#ccfbf1',
|
||||||
|
600: '#0d9488',
|
||||||
|
700: '#0f766e',
|
||||||
|
800: '#115e59',
|
||||||
|
900: '#134e4a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
base: ['1rem', { lineHeight: '1.5rem' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
7
manager_dashboard/vite.config.js
Normal file
7
manager_dashboard/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5174 },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user