diff --git a/docker-compose.yml b/docker-compose.yml
index cc7d967..21c7a17 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,3 +26,17 @@ services:
depends_on:
- backend
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
diff --git a/manager_dashboard/index.html b/manager_dashboard/index.html
new file mode 100644
index 0000000..d563033
--- /dev/null
+++ b/manager_dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ POS Manager
+
+
+
+
+
+
diff --git a/manager_dashboard/package.json b/manager_dashboard/package.json
new file mode 100644
index 0000000..3b23a76
--- /dev/null
+++ b/manager_dashboard/package.json
@@ -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"
+ }
+}
diff --git a/manager_dashboard/postcss.config.js b/manager_dashboard/postcss.config.js
new file mode 100644
index 0000000..be56e0e
--- /dev/null
+++ b/manager_dashboard/postcss.config.js
@@ -0,0 +1,3 @@
+export default {
+ plugins: { tailwindcss: {}, autoprefixer: {} },
+}
diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx
new file mode 100644
index 0000000..d6fb3fb
--- /dev/null
+++ b/manager_dashboard/src/App.jsx
@@ -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 :
+}
+
+export default function App() {
+ return (
+
+
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/api/client.js b/manager_dashboard/src/api/client.js
new file mode 100644
index 0000000..fcea818
--- /dev/null
+++ b/manager_dashboard/src/api/client.js
@@ -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
diff --git a/manager_dashboard/src/components/ConfirmModal.jsx b/manager_dashboard/src/components/ConfirmModal.jsx
new file mode 100644
index 0000000..60b6365
--- /dev/null
+++ b/manager_dashboard/src/components/ConfirmModal.jsx
@@ -0,0 +1,14 @@
+export default function ConfirmModal({ title, message, confirmLabel = 'Επιβεβαίωση', confirmClass = 'btn-danger', onConfirm, onCancel }) {
+ return (
+
+
+
{title}
+ {message &&
{message}
}
+
+
+
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx
new file mode 100644
index 0000000..aa07cb5
--- /dev/null
+++ b/manager_dashboard/src/components/Sidebar.jsx
@@ -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 (
+
+ )
+}
diff --git a/manager_dashboard/src/components/StatusBadge.jsx b/manager_dashboard/src/components/StatusBadge.jsx
new file mode 100644
index 0000000..026d008
--- /dev/null
+++ b/manager_dashboard/src/components/StatusBadge.jsx
@@ -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 (
+
+ {label}
+
+ )
+}
diff --git a/manager_dashboard/src/index.css b/manager_dashboard/src/index.css
new file mode 100644
index 0000000..397fd9c
--- /dev/null
+++ b/manager_dashboard/src/index.css
@@ -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;
+ }
+}
diff --git a/manager_dashboard/src/layouts/AppLayout.jsx b/manager_dashboard/src/layouts/AppLayout.jsx
new file mode 100644
index 0000000..452499c
--- /dev/null
+++ b/manager_dashboard/src/layouts/AppLayout.jsx
@@ -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 (
+
+
+
+ {/* Top bar */}
+
+ {/* Page content */}
+
+
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/main.jsx b/manager_dashboard/src/main.jsx
new file mode 100644
index 0000000..4d57aa5
--- /dev/null
+++ b/manager_dashboard/src/main.jsx
@@ -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(
+
+
+
+
+
+
+)
diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx
new file mode 100644
index 0000000..0896158
--- /dev/null
+++ b/manager_dashboard/src/pages/DashboardPage.jsx
@@ -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 Φόρτωση…
+ }
+
+ return (
+
+
+
Dashboard
+
+ {FILTERS.map(f => (
+
+ ))}
+
+
+
+ {filtered.length === 0 && (
+
Δεν βρέθηκαν τραπέζια.
+ )}
+
+
+ {filtered.map(({ table, order, tableStatus }) => (
+
+ ))}
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/LoginPage.jsx b/manager_dashboard/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..df67a49
--- /dev/null
+++ b/manager_dashboard/src/pages/LoginPage.jsx
@@ -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 (
+
+
+
POS Manager
+
Πίνακας Διαχείρισης
+
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/OrderDetailPage.jsx b/manager_dashboard/src/pages/OrderDetailPage.jsx
new file mode 100644
index 0000000..1264098
--- /dev/null
+++ b/manager_dashboard/src/pages/OrderDetailPage.jsx
@@ -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 Φόρτωση…
+ if (!order) return Παραγγελία δεν βρέθηκε.
+
+ 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 (
+
+ {!propOrderId && (
+
+ )}
+
+ {/* Header */}
+
+
+
Παραγγελία #{order.id}
+
+ Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
+ {order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
+
+
+
+
+ €{total.toFixed(2)}
+
+
+
+ {/* Waiters */}
+
+
Σερβιτόροι
+
+ {order.waiters.map(w => (
+
+ {waiterMap[w.waiter_id] || `#${w.waiter_id}`}
+ {isOpen && !readOnly && (
+
+ )}
+
+ ))}
+ {isOpen && !readOnly && (
+
+ )}
+
+
+
+ {/* Items */}
+
+
+
Αντικείμενα
+
+ {order.items.length === 0 && (
+
Κανένα αντικείμενο.
+ )}
+ {order.items.map(item => (
+
+
+
{item.product?.name ?? `#${item.product_id}`}
+ {item.notes &&
{item.notes}
}
+
x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ
+
+
+
+ €{itemTotal(item)}
+ {isOpen && !readOnly && item.status === 'active' && (
+ <>
+
+
+ >
+ )}
+
+
+ ))}
+
+
+ {/* Actions */}
+ {isOpen && !readOnly && (
+
+ {activeItems.length > 0 && (
+
+ )}
+
+
+
+ )}
+
+ {confirmAction && (
+
setConfirmAction(null)}
+ />
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/ProductsPage.jsx b/manager_dashboard/src/pages/ProductsPage.jsx
new file mode 100644
index 0000000..984ba88
--- /dev/null
+++ b/manager_dashboard/src/pages/ProductsPage.jsx
@@ -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 (
+
+ {/* Left: Categories */}
+
+
+ {/* Right: Products */}
+
+
+
+ Προϊόντα {selectedCat ? `— ${categories.find(c => c.id === selectedCat)?.name}` : ''}
+
+
+
+
+ {products.length === 0 && (
+
Δεν υπάρχουν προϊόντα.
+ )}
+
+
+ {products.map(p => (
+
+
+
{p.name}
+
+ {categories.find(c => c.id === p.category_id)?.name ?? '—'} ·
+ €{p.base_price.toFixed(2)}
+ {p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`}
+
+
+
+
+
+
+ ))}
+
+
+
+ {/* Category form modal */}
+ {editCat !== null && (
+
saveCat.mutate({ name, sort_order: Number(sort_order) })}
+ onClose={() => setEditCat(null)}
+ />
+ )}
+
+ {/* Product form panel */}
+ {editProduct !== null && (
+ saveProduct.mutate(body)}
+ onClose={() => setEditProduct(null)}
+ />
+ )}
+
+ {confirmDelete && (
+ setConfirmDelete(null)}
+ />
+ )}
+
+ )
+}
+
+function CategoryFormModal({ cat, onSave, onClose }) {
+ const [name, setName] = useState(cat.name || '')
+ const [sort, setSort] = useState(cat.sort_order ?? 0)
+ return (
+
+
+
{cat.id ? 'Επεξεργασία κατηγορίας' : 'Νέα κατηγορία'}
+
+
+ setName(e.target.value)} autoFocus />
+
+
+
+ setSort(e.target.value)} />
+
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+
{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}
+
+
+
+
+
+ setField('name', e.target.value)} autoFocus />
+
+
+
+
+
+
+
+ setField('base_price', e.target.value)} />
+
+
+
+
+
+
+
+ {/* Options */}
+
+
+ {/* Ingredients */}
+
+
+
+
+
+ {form.ingredients.map((ing, i) => (
+
+ setIngredient(i, e.target.value)} />
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/ReportsPage.jsx b/manager_dashboard/src/pages/ReportsPage.jsx
new file mode 100644
index 0000000..9d44fe5
--- /dev/null
+++ b/manager_dashboard/src/pages/ReportsPage.jsx
@@ -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 (
+
+
Αναφορές
+
+
+
+
+
+
+ {tab === 'shift' &&
}
+ {tab === 'history' &&
}
+
+ )
+}
+
+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 (
+
+
+
+
+ setDate(e.target.value)} />
+
+ {rows.length > 0 && (
+
+ )}
+
+
+ {isLoading &&
Φόρτωση…
}
+
+ {!isLoading && rows.length === 0 && (
+
Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.
+ )}
+
+ {rows.length > 0 && (
+
+
+
+
+ {Object.keys(rows[0]).map(h => (
+ | {h} |
+ ))}
+
+
+
+ {rows.map((r, i) => (
+
+ {Object.values(r).map((v, j) => (
+ | {v} |
+ ))}
+
+ ))}
+
+
+
+ | Σύνολο |
+ {rows.reduce((s, r) => s + r['Παραγγελίες'], 0)} |
+ {rows.reduce((s, r) => s + r['Αντικείμενα'], 0)} |
+ €{grandTotal.toFixed(2)} |
+
+
+
+
+ )}
+
+ )
+}
+
+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 (
+
+
+
+
+ setF('from', e.target.value)} />
+
+
+
+ setF('to', e.target.value)} />
+
+
+
+
+
+
+
+ {isLoading &&
Φόρτωση…
}
+
+ {!isLoading && orders.length === 0 && (
+
Δεν βρέθηκαν παραγγελίες.
+ )}
+
+ {orders.length > 0 && (
+
+
+
+
+ | # |
+ Τραπέζι |
+ Ανοίχτηκε |
+ Κατάσταση |
+ Σύνολο |
+ |
+
+
+
+ {orders.map(o => {
+ const total = o.items
+ .filter(i => i.status !== 'cancelled')
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ return (
+
+ | {o.id} |
+ {o.table_id} |
+
+ {new Date(o.opened_at).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })}
+ |
+ |
+ €{total.toFixed(2)} |
+
+
+ |
+
+ )
+ })}
+
+
+
+ )}
+
+ {/* Pagination */}
+
+
+ Σελίδα {page}
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/SettingsPage.jsx b/manager_dashboard/src/pages/SettingsPage.jsx
new file mode 100644
index 0000000..08079cd
--- /dev/null
+++ b/manager_dashboard/src/pages/SettingsPage.jsx
@@ -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 Φόρτωση…
+
+ return (
+
+
Ρυθμίσεις
+
+ {/* System info */}
+
+
Σύστημα
+
+
Uptime
+
{formatUptime(status?.uptime_seconds ?? 0)}
+
Άδεια χρήσης
+
+ {status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
+
+
Κατάσταση
+
+ {status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
+
+ {status?.expires_at && (
+ <>
+
Λήξη άδειας
+
{new Date(status.expires_at).toLocaleDateString('el-GR')}
+ >
+ )}
+
+
+
+ {/* Printers */}
+
+
+
Εκτυπωτές
+
+
+ {(!status?.printers || status.printers.length === 0) && (
+
Δεν βρέθηκαν εκτυπωτές.
+ )}
+
+ {status?.printers?.map(p => (
+
+
+
+ {p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
+
+
+
+ ))}
+
+
+ {/* Sysadmin-only section */}
+ {user?.role === 'sysadmin' && (
+
+
Sysadmin
+
Έλεγχος κλειδώματος συστήματος.
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/TablesPage.jsx b/manager_dashboard/src/pages/TablesPage.jsx
new file mode 100644
index 0000000..39bb3e9
--- /dev/null
+++ b/manager_dashboard/src/pages/TablesPage.jsx
@@ -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 Φόρτωση…
+
+ return (
+
+
+
Τραπέζια
+
+
+
+
+ {tables.length === 0 && (
+
Δεν υπάρχουν τραπέζια.
+ )}
+ {tables.map(t => (
+
+
{t.number}
+
{t.label || '—'}
+
+
+
+ ))}
+
+
+ {/* Add table modal */}
+ {addModal && (
+
createTable.mutate({ number: Number(form.number), label: form.label || null })}
+ onClose={() => setAddModal(false)}
+ />
+ )}
+
+ {/* Edit table modal */}
+ {editModal && (
+ setEditModal(t => ({ ...t, ...f }))}
+ onSave={() => updateTable.mutate({ id: editModal.id, number: Number(editModal.number), label: editModal.label || null })}
+ onClose={() => setEditModal(null)}
+ />
+ )}
+
+ {confirmDeactivate !== null && (
+ deactivateTable.mutate(confirmDeactivate)}
+ onCancel={() => setConfirmDeactivate(null)}
+ />
+ )}
+
+ )
+}
+
+function TableModal({ title, form, setForm, onSave, onClose }) {
+ return (
+
+
+
{title}
+
+
+ setForm(f => ({ ...f, number: e.target.value }))} autoFocus />
+
+
+
+ setForm(f => ({ ...f, label: e.target.value }))} />
+
+
+
+
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/WaitersPage.jsx b/manager_dashboard/src/pages/WaitersPage.jsx
new file mode 100644
index 0000000..0044e1e
--- /dev/null
+++ b/manager_dashboard/src/pages/WaitersPage.jsx
@@ -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 (
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {DIGITS.map((d, i) => (
+
+ ))}
+
+
+ )
+}
+
+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 Φόρτωση…
+
+ return (
+
+
+
Σερβιτόροι
+
+
+
+
+ {waiters.length === 0 && (
+
Δεν υπάρχουν σερβιτόροι.
+ )}
+ {waiters.map(w => (
+
+
+
{w.username}
+
{w.role}
+
+
+ {w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
+
+
+
+
+
+ ))}
+
+
+ {/* Add waiter modal */}
+ {addModal && (
+
+
+
Νέος σερβιτόρος
+
+
+ setNewForm(f => ({ ...f, username: e.target.value }))} autoFocus />
+
+
+
+
+
+
+
+
setNewForm(f => ({ ...f, pin }))} />
+
+
+
+
+
+
+
+ )}
+
+ {/* Reset PIN modal */}
+ {pinModal !== null && (
+
+
+
Reset PIN — {waiters.find(w => w.id === pinModal)?.username}
+
+
+
+
+
+
+
+ )}
+
+ {confirmDelete !== null && (
+
deleteWaiter.mutate(confirmDelete)}
+ onCancel={() => setConfirmDelete(null)}
+ />
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/store/authStore.js b/manager_dashboard/src/store/authStore.js
new file mode 100644
index 0000000..552aa8c
--- /dev/null
+++ b/manager_dashboard/src/store/authStore.js
@@ -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
diff --git a/manager_dashboard/tailwind.config.js b/manager_dashboard/tailwind.config.js
new file mode 100644
index 0000000..b06603d
--- /dev/null
+++ b/manager_dashboard/tailwind.config.js
@@ -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: [],
+}
diff --git a/manager_dashboard/vite.config.js b/manager_dashboard/vite.config.js
new file mode 100644
index 0000000..661a0d2
--- /dev/null
+++ b/manager_dashboard/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: { port: 5174 },
+})