Add Polaris frontend: storefront + admin console (React 18/Vite/TS/Tailwind)
- Shared src/lib/api.ts fetch wrapper -> /api with Cache-Control: no-store - Storefront: home hero, shop grid (category filter + search), product detail, cart, checkout (POST /api/orders -> confirmation + order number), order tracking - Admin: JWT login, today KPI dashboard (revenue/orders/gross/net/margin/aov/refunds), top products, supplier performance, product import, lifecycle view, order list with tracking simulation - Multi-stage Dockerfile (Vite build -> nginx static + /api proxy -> api:8000) - nginx.conf with SPA fallback and no-store HTML caching - mock/mock_api.py for local verification (excluded from image)
This commit is contained in:
27
frontend/src/admin/AdminApp.tsx
Normal file
27
frontend/src/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { auth } from '../lib/api';
|
||||
import AdminLayout from './AdminLayout';
|
||||
import Dashboard from './Dashboard';
|
||||
import Login from './Login';
|
||||
import Orders from './Orders';
|
||||
import Products from './Products';
|
||||
|
||||
export default function AdminApp() {
|
||||
const [authed, setAuthed] = useState<boolean>(() => Boolean(auth.token()));
|
||||
|
||||
if (!authed) {
|
||||
return <Login onSuccess={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
96
frontend/src/admin/AdminLayout.tsx
Normal file
96
frontend/src/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { auth } from '../lib/api';
|
||||
import {
|
||||
IconBoxes,
|
||||
IconClipboardList,
|
||||
IconExternalLink,
|
||||
IconDashboard,
|
||||
IconLogOut,
|
||||
IconStar,
|
||||
} from '../components/Icons';
|
||||
|
||||
const item = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-xl border px-4 py-2.5 text-sm font-medium transition ${
|
||||
isActive
|
||||
? 'border-indigo-400/20 bg-indigo-500/15 text-indigo-200'
|
||||
: 'border-transparent text-slate-400 hover:bg-white/[0.05] hover:text-white'
|
||||
}`;
|
||||
|
||||
const Brand = () => (
|
||||
<Link to="/admin" className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-glow">
|
||||
<IconStar className="h-5 w-5 text-white" />
|
||||
</span>
|
||||
<span className="text-lg font-bold tracking-tight text-white">
|
||||
Polaris<span className="text-indigo-400">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = () => {
|
||||
auth.clear();
|
||||
navigate('/admin', { replace: true });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const nav = (
|
||||
<>
|
||||
<NavLink to="/admin" end className={item}>
|
||||
<IconDashboard className="h-4 w-4" /> Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/admin/products" className={item}>
|
||||
<IconBoxes className="h-4 w-4" /> Products
|
||||
</NavLink>
|
||||
<NavLink to="/admin/orders" className={item}>
|
||||
<IconClipboardList className="h-4 w-4" /> Orders
|
||||
</NavLink>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-app flex min-h-screen">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 flex-col border-r border-white/[0.06] bg-night-900/50 px-4 py-6 backdrop-blur-xl lg:flex">
|
||||
<Brand />
|
||||
<nav className="mt-8 flex flex-1 flex-col gap-1.5">{nav}</nav>
|
||||
<div className="space-y-1.5 border-t border-white/[0.06] pt-4">
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-400 transition hover:bg-white/[0.05] hover:text-white"
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" /> View storefront
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-400 transition hover:bg-rose-500/10 hover:text-rose-300"
|
||||
>
|
||||
<IconLogOut className="h-4 w-4" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Mobile header */}
|
||||
<header className="sticky top-0 z-30 border-b border-white/[0.06] bg-night-950/70 backdrop-blur-xl lg:hidden">
|
||||
<div className="flex h-16 items-center justify-between px-4">
|
||||
<Brand />
|
||||
<button type="button" onClick={logout} className="btn-ghost !px-3 !py-2 text-xs">
|
||||
<IconLogOut className="h-4 w-4" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-1 overflow-x-auto px-4 pb-3">{nav}</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
frontend/src/admin/Dashboard.tsx
Normal file
207
frontend/src/admin/Dashboard.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, asList } from '../lib/api';
|
||||
import type { AnalyticsSummary, SupplierPerformance, TopProduct } from '../lib/types';
|
||||
import { money, num, pct } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import {
|
||||
IconBarChart,
|
||||
IconClipboardList,
|
||||
IconDollarSign,
|
||||
IconRefresh,
|
||||
IconRotateCcw,
|
||||
IconShoppingBag,
|
||||
IconTrendingUp,
|
||||
IconWallet,
|
||||
} from '../components/Icons';
|
||||
|
||||
interface DashboardData {
|
||||
summary: AnalyticsSummary | null;
|
||||
topProducts: TopProduct[];
|
||||
suppliers: SupplierPerformance[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData>({ summary: null, topProducts: [], suppliers: [], error: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summary, topProducts, suppliers] = await Promise.all([
|
||||
api.get<AnalyticsSummary>('/analytics/summary'),
|
||||
api.get<TopProduct[]>('/analytics/top-products'),
|
||||
api.get<SupplierPerformance[]>('/analytics/supplier-performance'),
|
||||
]);
|
||||
setData({ summary, topProducts: asList(topProducts), suppliers: asList(suppliers), error: null });
|
||||
} catch (e) {
|
||||
setData((d) => ({ ...d, error: e instanceof Error ? e.message : 'Failed to load analytics' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-8 w-8 text-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.error && !data.summary) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Could not load the dashboard"
|
||||
hint={data.error}
|
||||
action={
|
||||
<button type="button" className="btn-primary" onClick={() => void load()}>
|
||||
<IconRefresh className="h-4 w-4" /> Retry
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const s = data.summary;
|
||||
const gross = s?.gross_profit ?? s?.profit ?? null;
|
||||
const net = s?.net_profit ?? s?.profit ?? null;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Revenue', value: money(s?.revenue), icon: IconDollarSign, tone: 'from-indigo-500/25 to-indigo-500/5 text-indigo-300' },
|
||||
{ label: 'Orders', value: num(s?.orders), icon: IconClipboardList, tone: 'from-sky-500/25 to-sky-500/5 text-sky-300' },
|
||||
{ label: 'Gross profit', value: money(gross), icon: IconTrendingUp, tone: 'from-violet-500/25 to-violet-500/5 text-violet-300' },
|
||||
{ label: 'Net profit', value: money(net), icon: IconWallet, tone: 'from-emerald-500/25 to-emerald-500/5 text-emerald-300' },
|
||||
{ label: 'Margin', value: pct(s?.margin), icon: IconBarChart, tone: 'from-fuchsia-500/25 to-fuchsia-500/5 text-fuchsia-300' },
|
||||
{ label: 'AOV', value: money(s?.aov), icon: IconShoppingBag, tone: 'from-amber-500/25 to-amber-500/5 text-amber-300' },
|
||||
{ label: 'Refunds', value: num(s?.refunds ?? 0), icon: IconRotateCcw, tone: 'from-rose-500/25 to-rose-500/5 text-rose-300' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Today</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">Live snapshot of the Polaris pipeline.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} className="btn-ghost !py-2 text-sm">
|
||||
<IconRefresh className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="glass glass-hover p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br ${k.tone}`}>
|
||||
<k.icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xl font-extrabold text-white">{k.value}</p>
|
||||
<p className="mt-0.5 text-xs font-medium text-slate-400">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<section className="glass overflow-hidden">
|
||||
<header className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="font-semibold text-white">Top products</h2>
|
||||
<p className="text-xs text-slate-500">By revenue today</p>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[480px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">#</th>
|
||||
<th className="th">Product</th>
|
||||
<th className="th text-right">Units</th>
|
||||
<th className="th text-right">Revenue</th>
|
||||
<th className="th text-right">Profit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.topProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="td py-8 text-center text-slate-500">
|
||||
No sales yet today.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.topProducts.slice(0, 10).map((p, i) => (
|
||||
<tr key={p.product_id ?? i} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td font-bold text-slate-500">{i + 1}</td>
|
||||
<td className="td">
|
||||
<p className="font-medium text-slate-200">{p.title}</p>
|
||||
{p.sku ? <p className="text-xs text-slate-500">{p.sku}</p> : null}
|
||||
</td>
|
||||
<td className="td text-right">{num(p.units_sold)}</td>
|
||||
<td className="td text-right font-semibold text-white">{money(p.revenue)}</td>
|
||||
<td className="td text-right text-emerald-300">{money(p.profit)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass overflow-hidden">
|
||||
<header className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="font-semibold text-white">Supplier performance</h2>
|
||||
<p className="text-xs text-slate-500">Rolling reliability snapshot</p>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">Supplier</th>
|
||||
<th className="th text-right">Fulfillment</th>
|
||||
<th className="th text-right">Ship (d)</th>
|
||||
<th className="th text-right">Cancels</th>
|
||||
<th className="th text-right">Stock acc.</th>
|
||||
<th className="th text-right">Returns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.suppliers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="td py-8 text-center text-slate-500">
|
||||
No supplier data yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.suppliers.map((sp) => (
|
||||
<tr key={sp.supplier_id} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td">
|
||||
<p className="font-medium text-slate-200">{sp.supplier_name ?? sp.supplier_id}</p>
|
||||
<div className="mt-1.5 h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-400"
|
||||
style={{ width: `${Math.min(100, Math.max(0, (sp.fulfillment_rate ?? 0) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="td text-right font-semibold text-white">{pct(sp.fulfillment_rate)}</td>
|
||||
<td className="td text-right">{sp.avg_shipping_days ?? '—'}</td>
|
||||
<td className="td text-right text-rose-300">{pct(sp.cancellation_rate)}</td>
|
||||
<td className="td text-right text-emerald-300">{pct(sp.stock_accuracy)}</td>
|
||||
<td className="td text-right text-amber-300">{pct(sp.return_rate)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/admin/Login.tsx
Normal file
88
frontend/src/admin/Login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, auth } from '../lib/api';
|
||||
import type { LoginResponse } from '../lib/types';
|
||||
import { IconLock, IconStar } from '../components/Icons';
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.post<LoginResponse>('/auth/login', { email, password });
|
||||
auth.save(res.access_token);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-app flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-glow">
|
||||
<IconStar className="h-7 w-7 text-white" />
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white">Polaris Admin</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">Mission control for the reseller pipeline.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="glass space-y-4 p-6">
|
||||
<div>
|
||||
<label className="label" htmlFor="admin-email">Email</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@polaris.shop"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="admin-password">Password</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary w-full !py-3">
|
||||
<IconLock className="h-4 w-4" />
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-500">
|
||||
<Link to="/" className="transition hover:text-indigo-300">
|
||||
← Back to storefront
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/admin/Orders.tsx
Normal file
137
frontend/src/admin/Orders.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, asList } from '../lib/api';
|
||||
import type { Order, OrderStatus } from '../lib/types';
|
||||
import { date, money, orderStatusLabel } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { IconRefresh, IconZap } from '../components/Icons';
|
||||
|
||||
const FLOWABLE: OrderStatus[] = ['new', 'paid', 'fraud_check', 'supplier_order', 'confirmed', 'shipped'];
|
||||
|
||||
export default function Orders() {
|
||||
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [simulating, setSimulating] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<Order[]>('/orders');
|
||||
setOrders(asList(res));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load orders');
|
||||
setOrders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const simulate = async (order: Order) => {
|
||||
setSimulating(order.id);
|
||||
try {
|
||||
await api.post<unknown>(`/orders/${order.id}/tracking`);
|
||||
await load();
|
||||
setFlash(`Tracking simulated for ${order.order_number} — status advanced.`);
|
||||
} catch (e) {
|
||||
setFlash(e instanceof Error ? e.message : 'Simulation failed');
|
||||
} finally {
|
||||
setSimulating(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Orders</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">{orders?.length ?? 0} orders in the pipeline.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} className="btn-ghost !py-2 text-sm">
|
||||
<IconRefresh className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{flash ? (
|
||||
<div className="rounded-xl border border-indigo-400/30 bg-indigo-500/10 p-3 text-sm text-indigo-200">
|
||||
{flash}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="glass overflow-hidden">
|
||||
{orders === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<EmptyState title="No orders yet" hint="Storefront orders will appear here in real time." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">Order</th>
|
||||
<th className="th">Placed</th>
|
||||
<th className="th text-right">Items</th>
|
||||
<th className="th text-right">Total</th>
|
||||
<th className="th text-right">Profit</th>
|
||||
<th className="th">Status</th>
|
||||
<th className="th">Tracking</th>
|
||||
<th className="th text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td">
|
||||
<p className="font-mono font-semibold text-indigo-300">{o.order_number}</p>
|
||||
{o.customer_email ? <p className="text-xs text-slate-500">{o.customer_email}</p> : null}
|
||||
</td>
|
||||
<td className="td whitespace-nowrap">{date(o.created_at)}</td>
|
||||
<td className="td text-right">{o.items?.reduce((n, it) => n + it.qty, 0) ?? 0}</td>
|
||||
<td className="td text-right font-semibold text-white">{money(o.retail_total)}</td>
|
||||
<td className="td text-right text-emerald-300">{money(o.profit)}</td>
|
||||
<td className="td">
|
||||
<StatusBadge kind="order" status={o.status} />
|
||||
</td>
|
||||
<td className="td">
|
||||
{o.tracking ? (
|
||||
<div>
|
||||
<p className="font-mono text-xs text-slate-300">{o.tracking}</p>
|
||||
{o.carrier ? <p className="text-[11px] text-slate-500">{o.carrier}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="td text-right">
|
||||
{FLOWABLE.includes(o.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={simulating === o.id}
|
||||
onClick={() => void simulate(o)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-indigo-400/30 bg-indigo-500/10 px-3 py-1.5 text-xs font-semibold text-indigo-300 transition hover:bg-indigo-500/20 disabled:opacity-50"
|
||||
>
|
||||
<IconZap className="h-3.5 w-3.5" />
|
||||
{simulating === o.id ? 'Simulating…' : 'Simulate tracking'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">{orderStatusLabel(o.status)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
frontend/src/admin/Products.tsx
Normal file
248
frontend/src/admin/Products.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, asList, qs } from '../lib/api';
|
||||
import type { Product, ProductStatus, Supplier } from '../lib/types';
|
||||
import { money, PRODUCT_STATUSES, productStatusLabel } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { IconRefresh, IconUpload } from '../components/Icons';
|
||||
|
||||
const STATUS_FILTERS: (ProductStatus | '')[] = ['', ...PRODUCT_STATUSES];
|
||||
|
||||
export default function Products() {
|
||||
// ── lifecycle ──
|
||||
const [products, setProducts] = useState<Product[] | null>(null);
|
||||
const [status, setStatus] = useState<ProductStatus | ''>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
// ── import ──
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [supplierId, setSupplierId] = useState('');
|
||||
const [feedType, setFeedType] = useState<'csv' | 'xml' | 'json'>('csv');
|
||||
const [payload, setPayload] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importMsg, setImportMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<Product[]>(`/products${qs({ status, limit: 200 })}`);
|
||||
setProducts(asList(res));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
setProducts([]);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Supplier[]>('/suppliers')
|
||||
.then((res) => {
|
||||
const list = asList(res);
|
||||
setSuppliers(list);
|
||||
if (list.length > 0) setSupplierId(list[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggle = async (p: Product) => {
|
||||
const isLive = p.status === 'published' || p.status === 'monitored';
|
||||
setBusyId(p.id);
|
||||
try {
|
||||
await api.post<unknown>(`/products/${p.id}/${isLive ? 'pause' : 'publish'}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Action failed');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const doImport = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setImporting(true);
|
||||
setImportMsg(null);
|
||||
try {
|
||||
const res = await api.post<{ imported?: number; message?: string }>('/products/import', {
|
||||
supplier_id: supplierId,
|
||||
feed_type: feedType,
|
||||
data_or_url: payload,
|
||||
});
|
||||
setImportMsg({
|
||||
ok: true,
|
||||
text: res?.message ?? `Import accepted${res?.imported != null ? ` — ${res.imported} products` : ''}. Refresh to see new products.`,
|
||||
});
|
||||
setPayload('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setImportMsg({ ok: false, text: err instanceof Error ? err.message : 'Import failed' });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Products</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">Import feeds and manage the product lifecycle.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Import ── */}
|
||||
<section className="glass p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUpload className="h-5 w-5 text-indigo-300" />
|
||||
<h2 className="font-semibold text-white">Import a product feed</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Paste feed data or a URL — the pipeline analyzes and prices every item automatically.
|
||||
</p>
|
||||
<form onSubmit={doImport} className="mt-5 grid gap-4 lg:grid-cols-[200px_160px_1fr_auto]">
|
||||
<div>
|
||||
<label className="label" htmlFor="imp-supplier">Supplier</label>
|
||||
<select id="imp-supplier" className="input" value={supplierId} onChange={(e) => setSupplierId(e.target.value)} required>
|
||||
{suppliers.length === 0 ? <option value="">No suppliers…</option> : null}
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="imp-type">Feed type</label>
|
||||
<select id="imp-type" className="input" value={feedType} onChange={(e) => setFeedType(e.target.value as 'csv' | 'xml' | 'json')}>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="imp-data">Feed data or URL</label>
|
||||
<textarea
|
||||
id="imp-data"
|
||||
className="input min-h-[96px] resize-none font-mono !text-xs"
|
||||
value={payload}
|
||||
onChange={(e) => setPayload(e.target.value)}
|
||||
placeholder={'sku,title,category,cost,retail_price\nAUR-01,Aurora Lamp,Home,12.50,38.50'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button type="submit" disabled={importing || suppliers.length === 0} className="btn-primary w-full">
|
||||
{importing ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{importMsg ? (
|
||||
<div
|
||||
className={`mt-4 rounded-xl border p-3 text-sm ${
|
||||
importMsg.ok
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-rose-400/30 bg-rose-500/10 text-rose-300'
|
||||
}`}
|
||||
>
|
||||
{importMsg.text}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Lifecycle ── */}
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-white">Lifecycle</h2>
|
||||
<p className="text-xs text-slate-500">{products?.length ?? 0} products</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} className="btn-ghost !py-2 text-sm">
|
||||
<IconRefresh className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<button
|
||||
key={s || 'all'}
|
||||
type="button"
|
||||
onClick={() => setStatus(s)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
status === s
|
||||
? 'border-indigo-400/50 bg-indigo-500/20 text-indigo-200'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:bg-white/[0.07] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{s ? productStatusLabel(s) : 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="glass mt-4 overflow-hidden">
|
||||
{products === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<EmptyState title="No products match this status" hint="Adjust the filter or import a new feed." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">Product</th>
|
||||
<th className="th">Category</th>
|
||||
<th className="th text-right">Cost</th>
|
||||
<th className="th text-right">Retail</th>
|
||||
<th className="th">Status</th>
|
||||
<th className="th text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => {
|
||||
const isLive = p.status === 'published' || p.status === 'monitored';
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td">
|
||||
<p className="font-medium text-slate-200">{p.title}</p>
|
||||
<p className="text-xs text-slate-500">{p.sku}</p>
|
||||
</td>
|
||||
<td className="td">{p.category ?? '—'}</td>
|
||||
<td className="td text-right">{money(p.cost)}</td>
|
||||
<td className="td text-right font-semibold text-white">{money(p.retail_price)}</td>
|
||||
<td className="td">
|
||||
<StatusBadge kind="product" status={p.status} />
|
||||
</td>
|
||||
<td className="td text-right">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === p.id}
|
||||
onClick={() => void toggle(p)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50 ${
|
||||
isLive
|
||||
? 'border-rose-400/30 bg-rose-500/10 text-rose-300 hover:bg-rose-500/20'
|
||||
: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{busyId === p.id ? '…' : isLive ? 'Pause' : 'Publish'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user