feat(connect): Phase 6 — menu-app and manager-app frontend SPAs
Two Vite + React 18 + Tailwind apps under connect_frontend/.
Both use React Router v6, Axios, react-hot-toast, and Lucide icons.
Neither uses localStorage — tokens live in sessionStorage (manager-app)
or component state (menu-app cart).
── menu-app (public menu + ordering) ──────────────────────────────
Routing (basename /menu):
/:siteSlug → MenuPage
/:siteSlug/order → CartPage
/:siteSlug/confirm/:ref → OrderConfirm
Pages:
MenuPage — fetches menu snapshot, category nav tabs, product
cards; floating CartButton; opens ProductModal
ProductModal — quantity picker, quick-option toggles, price/discount
display, "Add to order" with line total
CartPage — order type selector (dine_in/delivery), customer
details form, cart summary, submits to cloud API
OrderConfirm — polls GET /api/orders/status/:ref every 10s;
renders status icon + label; stops polling on
terminal states (delivered/rejected)
Components:
CategoryNav — horizontal scrollable pill tabs
ProductCard — image, name, description, price with discount badge;
greyed + "Out of stock" badge when unavailable
CartButton — fixed bottom bar with item count + total
── manager-app (remote dashboard) ─────────────────────────────────
Routing (basename /manage):
/login → LoginPage
/ → SiteSelectorPage (auto-navigates if one site)
/:siteId → DashboardPage
/:siteId/orders → IncomingOrdersPage
/:siteId/orders/history → OrderHistoryPage
/:siteId/orders/:id → OrderDetailPage
RequireAuth wrapper redirects to /login if no sessionStorage token
Pages:
LoginPage — email/password → POST /api/manager/login;
stores JWT in sessionStorage
SiteSelectorPage — lists accessible venues; auto-selects if one
DashboardPage — polls snapshot + pending orders every 15s;
2×2 stat grid + pending order list
IncomingOrdersPage — polls pending orders every 15s; order cards
OrderDetailPage — full order detail; Accept/Reject with optional
rejection reason; lifecycle progression buttons
(Preparing → Ready → Delivered etc.)
OrderHistoryPage — all orders with status filter tabs
Components:
RequireAuth — route guard using sessionStorage token
StatCard — labelled metric tile
StatusBadge — colour-coded status pill
OrderCard — summary card linking to OrderDetailPage
(no OrderCard uses useParams to get siteId for nav — wired correctly)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
15
connect_frontend/manager-app/index.html
Normal file
15
connect_frontend/manager-app/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Xenia Connect — Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
connect_frontend/manager-app/package.json
Normal file
26
connect_frontend/manager-app/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "manager-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
1
connect_frontend/manager-app/postcss.config.js
Normal file
1
connect_frontend/manager-app/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
24
connect_frontend/manager-app/src/App.jsx
Normal file
24
connect_frontend/manager-app/src/App.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SiteSelectorPage from './pages/SiteSelectorPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import IncomingOrdersPage from './pages/IncomingOrdersPage'
|
||||
import OrderDetailPage from './pages/OrderDetailPage'
|
||||
import OrderHistoryPage from './pages/OrderHistoryPage'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<SiteSelectorPage />} />
|
||||
<Route path="/:siteId" element={<DashboardPage />} />
|
||||
<Route path="/:siteId/orders" element={<IncomingOrdersPage />} />
|
||||
<Route path="/:siteId/orders/history" element={<OrderHistoryPage />} />
|
||||
<Route path="/:siteId/orders/:orderId" element={<OrderDetailPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
58
connect_frontend/manager-app/src/api.js
Normal file
58
connect_frontend/manager-app/src/api.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const BASE = import.meta.env.VITE_CLOUD_URL || ''
|
||||
|
||||
const api = axios.create({ baseURL: BASE })
|
||||
|
||||
// Attach manager JWT from sessionStorage on every request
|
||||
api.interceptors.request.use(config => {
|
||||
const token = sessionStorage.getItem('manager_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
// Force logout on 401
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
sessionStorage.removeItem('manager_token')
|
||||
window.location.href = '/manage/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
export async function managerLogin(email, password) {
|
||||
const { data } = await api.post('/api/manager/login', { email, password })
|
||||
return data // { access_token, token_type }
|
||||
}
|
||||
|
||||
export async function fetchSites() {
|
||||
const { data } = await api.get('/api/remote/sites')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchSnapshot(siteId) {
|
||||
const { data } = await api.get(`/api/remote/sites/${siteId}/snapshot`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchPendingOrders(siteId) {
|
||||
const { data } = await api.get(`/api/remote/sites/${siteId}/orders/pending`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchOrders(siteId, params = {}) {
|
||||
const { data } = await api.get(`/api/remote/sites/${siteId}/orders`, { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateOrderStatus(siteId, orderId, status, rejectionReason = null) {
|
||||
await api.patch(`/api/remote/sites/${siteId}/orders/${orderId}/status`, {
|
||||
status,
|
||||
rejection_reason: rejectionReason,
|
||||
})
|
||||
}
|
||||
41
connect_frontend/manager-app/src/components/OrderCard.jsx
Normal file
41
connect_frontend/manager-app/src/components/OrderCard.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Truck, UtensilsCrossed, Clock } from 'lucide-react'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
function timeAgo(iso) {
|
||||
const diff = Math.floor((Date.now() - new Date(iso)) / 1000)
|
||||
if (diff < 60) return `${diff}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
return `${Math.floor(diff / 3600)}h ago`
|
||||
}
|
||||
|
||||
export default function OrderCard({ order }) {
|
||||
const { siteId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(`/${siteId}/orders/${order.id}`)}
|
||||
className="w-full bg-white rounded-2xl shadow-sm p-4 text-left hover:shadow-md transition-shadow space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{order.order_type === 'delivery'
|
||||
? <Truck size={16} className="text-indigo-500" />
|
||||
: <UtensilsCrossed size={16} className="text-emerald-500" />}
|
||||
<span className="font-bold text-slate-800 font-mono">{order.public_ref}</span>
|
||||
</div>
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-600">{order.customer_name}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-slate-800">€{order.total?.toFixed(2)}</span>
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<Clock size={12} /> {timeAgo(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
|
||||
export default function RequireAuth() {
|
||||
const token = sessionStorage.getItem('manager_token')
|
||||
return token ? <Outlet /> : <Navigate to="/login" replace />
|
||||
}
|
||||
9
connect_frontend/manager-app/src/components/StatCard.jsx
Normal file
9
connect_frontend/manager-app/src/components/StatCard.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function StatCard({ label, value, sub, color = 'text-slate-800' }) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-1">
|
||||
<p className="text-xs text-slate-400 font-medium uppercase tracking-wide">{label}</p>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value ?? '—'}</p>
|
||||
{sub && <p className="text-xs text-slate-400">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
connect_frontend/manager-app/src/components/StatusBadge.jsx
Normal file
27
connect_frontend/manager-app/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
const STYLES = {
|
||||
pending_acceptance: 'bg-amber-100 text-amber-700',
|
||||
accepted: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-600',
|
||||
preparing: 'bg-blue-100 text-blue-700',
|
||||
ready: 'bg-teal-100 text-teal-700',
|
||||
out_for_delivery: 'bg-indigo-100 text-indigo-700',
|
||||
delivered: 'bg-slate-100 text-slate-500',
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
pending_acceptance: 'Pending',
|
||||
accepted: 'Accepted',
|
||||
rejected: 'Rejected',
|
||||
preparing: 'Preparing',
|
||||
ready: 'Ready',
|
||||
out_for_delivery: 'Out for delivery',
|
||||
delivered: 'Delivered',
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${STYLES[status] || 'bg-slate-100 text-slate-500'}`}>
|
||||
{LABELS[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
6
connect_frontend/manager-app/src/index.css
Normal file
6
connect_frontend/manager-app/src/index.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }
|
||||
15
connect_frontend/manager-app/src/main.jsx
Normal file
15
connect_frontend/manager-app/src/main.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename="/manage">
|
||||
<App />
|
||||
<Toaster position="top-center" />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
106
connect_frontend/manager-app/src/pages/DashboardPage.jsx
Normal file
106
connect_frontend/manager-app/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, RefreshCw, Bell } from 'lucide-react'
|
||||
import { fetchSnapshot, fetchPendingOrders } from '../api'
|
||||
import StatCard from '../components/StatCard'
|
||||
import OrderCard from '../components/OrderCard'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { siteId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [snapshot, setSnapshot] = useState(null)
|
||||
const [pending, setPending] = useState([])
|
||||
const [lastUpdated, setLastUpdated] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [snap, orders] = await Promise.all([
|
||||
fetchSnapshot(siteId).catch(() => null),
|
||||
fetchPendingOrders(siteId).catch(() => []),
|
||||
])
|
||||
setSnapshot(snap ? JSON.parse(snap.snapshot_json) : null)
|
||||
setPending(orders)
|
||||
setLastUpdated(new Date())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const timer = setInterval(load, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [siteId])
|
||||
|
||||
const stats = snapshot || {}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<button onClick={() => navigate('/')} className="text-slate-400 hover:text-slate-700">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-slate-800">Dashboard</h1>
|
||||
<button onClick={load} className="text-slate-400 hover:text-slate-700">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-5 space-y-5">
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="Open tables" value={stats.open_tables} />
|
||||
<StatCard label="Revenue today" value={stats.today_revenue != null ? `€${stats.today_revenue.toFixed(2)}` : null} color="text-emerald-600" />
|
||||
<StatCard label="Orders today" value={stats.today_orders} />
|
||||
<StatCard label="Online pending" value={stats.online_orders_pending} color={stats.online_orders_pending > 0 ? 'text-amber-600' : 'text-slate-800'} />
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-slate-400 text-right">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Pending orders */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Bell size={16} className={pending.length ? 'text-amber-500' : 'text-slate-300'} />
|
||||
Pending orders
|
||||
{pending.length > 0 && (
|
||||
<span className="bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate(`/${siteId}/orders`)}
|
||||
className="text-sky-500 text-sm font-medium hover:underline"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : pending.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-8 text-center text-slate-400 text-sm shadow-sm">
|
||||
No pending orders
|
||||
</div>
|
||||
) : (
|
||||
pending.map(o => <OrderCard key={o.id} order={o} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { fetchPendingOrders } from '../api'
|
||||
import OrderCard from '../components/OrderCard'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
|
||||
export default function IncomingOrdersPage() {
|
||||
const { siteId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchPendingOrders(siteId)
|
||||
setOrders(data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const timer = setInterval(load, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [siteId])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-slate-800">Incoming Orders</h1>
|
||||
<button onClick={load} className="text-slate-400 hover:text-slate-700">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-5 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center text-slate-400 shadow-sm">
|
||||
<p className="font-medium">No pending orders</p>
|
||||
<p className="text-sm mt-1">New orders will appear here automatically.</p>
|
||||
</div>
|
||||
) : (
|
||||
orders.map(o => <OrderCard key={o.id} order={o} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
connect_frontend/manager-app/src/pages/LoginPage.jsx
Normal file
77
connect_frontend/manager-app/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import { managerLogin } from '../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!email || !password) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await managerLogin(email, password)
|
||||
sessionStorage.setItem('manager_token', data.access_token)
|
||||
navigate('/', { replace: true })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Invalid credentials')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-100 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-12 h-12 bg-sky-500 rounded-xl flex items-center justify-center">
|
||||
<KeyRound className="text-white" size={22} />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-slate-800">Xenia Connect</h1>
|
||||
<p className="text-sm text-slate-500">Manager Dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-sky-400 focus:ring-1 focus:ring-sky-100"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-sky-400 focus:ring-1 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-sky-500 hover:bg-sky-600 disabled:opacity-60 text-white py-3 rounded-xl font-semibold transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-400 mt-4">Xenia POS · Remote Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
connect_frontend/manager-app/src/pages/OrderDetailPage.jsx
Normal file
175
connect_frontend/manager-app/src/pages/OrderDetailPage.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
|
||||
import { fetchOrders, updateOrderStatus } from '../api'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const NEXT_STATUSES = {
|
||||
accepted: [{ value: 'preparing', label: 'Mark Preparing' }],
|
||||
preparing: [
|
||||
{ value: 'ready', label: 'Mark Ready' },
|
||||
{ value: 'out_for_delivery', label: 'Out for Delivery' },
|
||||
],
|
||||
ready: [{ value: 'delivered', label: 'Mark Delivered' }],
|
||||
out_for_delivery: [{ value: 'delivered', label: 'Mark Delivered' }],
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { siteId, orderId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [order, setOrder] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [acting, setActing] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [showReject, setShowReject] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders(siteId)
|
||||
.then(list => setOrder(list.find(o => String(o.id) === orderId) || null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [siteId, orderId])
|
||||
|
||||
async function act(status, reason = null) {
|
||||
setActing(true)
|
||||
try {
|
||||
await updateOrderStatus(siteId, orderId, status, reason)
|
||||
toast.success(`Order ${status.replace('_', ' ')}`)
|
||||
navigate(-1)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Action failed')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!order) return (
|
||||
<div className="min-h-screen flex items-center justify-center text-slate-400">
|
||||
Order not found.
|
||||
</div>
|
||||
)
|
||||
|
||||
const items = (() => { try { return JSON.parse(order.items_json || '[]') } catch { return [] } })()
|
||||
const nextActions = NEXT_STATUSES[order.status] || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-slate-800 font-mono">{order.public_ref}</h1>
|
||||
<div className="ml-auto"><StatusBadge status={order.status} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg mx-auto px-4 py-5 space-y-4">
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
{order.order_type === 'delivery'
|
||||
? <Truck size={15} className="text-indigo-500" />
|
||||
: <UtensilsCrossed size={15} className="text-emerald-500" />}
|
||||
{order.order_type === 'delivery' ? 'Delivery' : 'Dine In'}
|
||||
</div>
|
||||
<p className="font-bold text-slate-800">{order.customer_name}</p>
|
||||
{order.customer_phone && <p className="text-sm text-slate-500">{order.customer_phone}</p>}
|
||||
{order.customer_address && (
|
||||
<p className="text-sm text-slate-500">{order.customer_address}</p>
|
||||
)}
|
||||
{order.customer_notes && (
|
||||
<p className="text-sm text-slate-500 italic">"{order.customer_notes}"</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between text-sm">
|
||||
<span className="text-slate-700">{item.quantity}× {item.name}</span>
|
||||
<span className="text-slate-600 font-medium">€{(item.unit_price * item.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>Subtotal</span><span>€{order.subtotal?.toFixed(2)}</span>
|
||||
</div>
|
||||
{order.delivery_fee > 0 && (
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>Delivery fee</span><span>€{order.delivery_fee?.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-slate-800">
|
||||
<span>Total</span><span>€{order.total?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{order.status === 'pending_acceptance' && !showReject && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => act('accepted')}
|
||||
disabled={acting}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-3.5 rounded-xl font-semibold transition-colors"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={acting}
|
||||
className="flex-1 bg-red-50 hover:bg-red-100 text-red-600 py-3.5 rounded-xl font-semibold transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReject && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700">Rejection reason (optional)</p>
|
||||
<input
|
||||
type="text"
|
||||
value={rejectReason}
|
||||
onChange={e => setRejectReason(e.target.value)}
|
||||
placeholder="e.g. Kitchen closed, item unavailable…"
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-red-300"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => act('rejected', rejectReason || null)}
|
||||
disabled={acting}
|
||||
className="flex-1 bg-red-500 hover:bg-red-600 disabled:opacity-60 text-white py-3 rounded-xl font-semibold"
|
||||
>
|
||||
Confirm Rejection
|
||||
</button>
|
||||
<button onClick={() => setShowReject(false)} className="px-4 py-3 rounded-xl text-slate-500 hover:bg-slate-100">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextActions.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => act(value)}
|
||||
disabled={acting}
|
||||
className="w-full bg-sky-500 hover:bg-sky-600 disabled:opacity-60 text-white py-3.5 rounded-xl font-semibold transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx
Normal file
74
connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { fetchOrders } from '../api'
|
||||
import OrderCard from '../components/OrderCard'
|
||||
|
||||
const STATUSES = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'pending_acceptance', label: 'Pending' },
|
||||
{ value: 'accepted', label: 'Accepted' },
|
||||
{ value: 'preparing', label: 'Preparing' },
|
||||
{ value: 'ready', label: 'Ready' },
|
||||
{ value: 'out_for_delivery', label: 'Delivery' },
|
||||
{ value: 'delivered', label: 'Delivered' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
]
|
||||
|
||||
export default function OrderHistoryPage() {
|
||||
const { siteId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [orders, setOrders] = useState([])
|
||||
const [filter, setFilter] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchOrders(siteId, filter ? { order_status: filter } : {})
|
||||
.then(setOrders)
|
||||
.finally(() => setLoading(false))
|
||||
}, [siteId, filter])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-slate-800">Order History</h1>
|
||||
</div>
|
||||
{/* Status filter tabs */}
|
||||
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1">
|
||||
{STATUSES.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setFilter(s.value)}
|
||||
className={`flex-shrink-0 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
filter === s.value
|
||||
? 'bg-sky-500 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-5 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center text-slate-400 shadow-sm">
|
||||
No orders found.
|
||||
</div>
|
||||
) : (
|
||||
orders.map(o => <OrderCard key={o.id} order={o} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx
Normal file
66
connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Store, LogOut } from 'lucide-react'
|
||||
import { fetchSites } from '../api'
|
||||
|
||||
export default function SiteSelectorPage() {
|
||||
const [sites, setSites] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSites()
|
||||
.then(data => {
|
||||
setSites(data)
|
||||
// Auto-navigate if only one site
|
||||
if (data.length === 1) navigate(`/${data[0].id}`, { replace: true })
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('manager_token')
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="max-w-md mx-auto space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Select Venue</h1>
|
||||
<button onClick={logout} className="text-slate-400 hover:text-slate-600 flex items-center gap-1 text-sm">
|
||||
<LogOut size={16} /> Log out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sites.length === 0 ? (
|
||||
<p className="text-slate-500 text-center py-12">No venues assigned to your account.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sites.map(site => (
|
||||
<button
|
||||
key={site.id}
|
||||
onClick={() => navigate(`/${site.id}`)}
|
||||
className="w-full bg-white rounded-2xl p-5 shadow-sm hover:shadow-md transition-shadow text-left flex items-center gap-4"
|
||||
>
|
||||
<div className="w-12 h-12 bg-sky-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Store className="text-sky-600" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800">{site.name}</p>
|
||||
<p className="text-xs text-slate-400 font-mono">{site.site_id}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
connect_frontend/manager-app/tailwind.config.js
Normal file
10
connect_frontend/manager-app/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
9
connect_frontend/manager-app/vite.config.js
Normal file
9
connect_frontend/manager-app/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/manage/',
|
||||
server: { host: '0.0.0.0', port: 5201 },
|
||||
build: { outDir: 'dist' },
|
||||
})
|
||||
Reference in New Issue
Block a user