Files
xenia-pos-cloud/connect_frontend/manager-app/src/pages/DashboardPage.jsx
bonamin 12bd589bb7 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>
2026-06-01 00:25:28 +03:00

107 lines
3.8 KiB
JavaScript

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>
)
}