From 8f52156f5be0c0361e0f512436763a468daa44d1 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 20 Apr 2026 17:20:46 +0300 Subject: [PATCH] =?UTF-8?q?Phase=203:=20scaffold=20Manager=20Dashboard=20?= =?UTF-8?q?=E2=80=94=20all=20pages,=20layout,=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker-compose.yml | 14 + manager_dashboard/index.html | 12 + manager_dashboard/package.json | 29 ++ manager_dashboard/postcss.config.js | 3 + manager_dashboard/src/App.jsx | 36 ++ manager_dashboard/src/api/client.js | 24 ++ .../src/components/ConfirmModal.jsx | 14 + manager_dashboard/src/components/Sidebar.jsx | 47 +++ .../src/components/StatusBadge.jsx | 18 + manager_dashboard/src/index.css | 36 ++ manager_dashboard/src/layouts/AppLayout.jsx | 49 +++ manager_dashboard/src/main.jsx | 21 ++ manager_dashboard/src/pages/DashboardPage.jsx | 117 +++++++ manager_dashboard/src/pages/LoginPage.jsx | 108 ++++++ .../src/pages/OrderDetailPage.jsx | 239 +++++++++++++ manager_dashboard/src/pages/ProductsPage.jsx | 315 ++++++++++++++++++ manager_dashboard/src/pages/ReportsPage.jsx | 209 ++++++++++++ manager_dashboard/src/pages/SettingsPage.jsx | 113 +++++++ manager_dashboard/src/pages/TablesPage.jsx | 128 +++++++ manager_dashboard/src/pages/WaitersPage.jsx | 169 ++++++++++ manager_dashboard/src/store/authStore.js | 18 + manager_dashboard/tailwind.config.js | 23 ++ manager_dashboard/vite.config.js | 7 + 23 files changed, 1749 insertions(+) create mode 100644 manager_dashboard/index.html create mode 100644 manager_dashboard/package.json create mode 100644 manager_dashboard/postcss.config.js create mode 100644 manager_dashboard/src/App.jsx create mode 100644 manager_dashboard/src/api/client.js create mode 100644 manager_dashboard/src/components/ConfirmModal.jsx create mode 100644 manager_dashboard/src/components/Sidebar.jsx create mode 100644 manager_dashboard/src/components/StatusBadge.jsx create mode 100644 manager_dashboard/src/index.css create mode 100644 manager_dashboard/src/layouts/AppLayout.jsx create mode 100644 manager_dashboard/src/main.jsx create mode 100644 manager_dashboard/src/pages/DashboardPage.jsx create mode 100644 manager_dashboard/src/pages/LoginPage.jsx create mode 100644 manager_dashboard/src/pages/OrderDetailPage.jsx create mode 100644 manager_dashboard/src/pages/ProductsPage.jsx create mode 100644 manager_dashboard/src/pages/ReportsPage.jsx create mode 100644 manager_dashboard/src/pages/SettingsPage.jsx create mode 100644 manager_dashboard/src/pages/TablesPage.jsx create mode 100644 manager_dashboard/src/pages/WaitersPage.jsx create mode 100644 manager_dashboard/src/store/authStore.js create mode 100644 manager_dashboard/tailwind.config.js create mode 100644 manager_dashboard/vite.config.js 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 */} +
+ {timeStr} +
+ {user?.username} + +
+
+ {/* 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

+

Πίνακας Διαχείρισης

+ +
+ setUsername(e.target.value)} + autoComplete="off" + autoFocus + /> + + {/* PIN display */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* PIN pad */} +
+ {DIGITS.map((d, i) => ( + + ))} +
+ + {error &&

{error}

} + + + +
+
+ ) +} 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 */} +
+
+ + +
+ {form.options.map((opt, i) => ( +
+ setOption(i, 'name', e.target.value)} /> + setOption(i, 'extra_cost', parseFloat(e.target.value) || 0)} /> + +
+ ))} +
+ + {/* 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 => ( + + ))} + + + + {rows.map((r, i) => ( + + {Object.values(r).map((v, j) => ( + + ))} + + ))} + + + + + + + + + +
{h}
{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.name}

+
+ + {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 }, +})