Milestone 3: catalog sync, batch transactions, and reports

- GET /api/v1/catalog/sync?since= — delta sync for Android offline-first
- POST /api/v1/transactions/batch — idempotency-keyed batch upload (207 Multi-Status),
  validates product ownership, skips duplicates silently
- GET /api/v1/transactions + /reports/summary — paginated list and aggregated
  revenue/tax/top-product reporting with date range filters
- ReportsPage: stat cards, payment method breakdown, top-10 products, transaction table
- Reports added to sidebar nav and router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 06:51:27 -05:00
parent c35f92f18b
commit 91e1a1ffbf
7 changed files with 562 additions and 6 deletions

View File

@@ -6,6 +6,7 @@ import DashboardPage from "./pages/DashboardPage";
import UsersPage from "./pages/UsersPage";
import CatalogPage from "./pages/CatalogPage";
import VendorPage from "./pages/VendorPage";
import ReportsPage from "./pages/ReportsPage";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@@ -44,6 +45,7 @@ export default function App() {
<Route path="catalog" element={<CatalogPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="vendor" element={<VendorPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@@ -7,6 +7,7 @@ const NAV = [
{ to: "/catalog", label: "Catalog" },
{ to: "/users", label: "Users" },
{ to: "/vendor", label: "Vendor" },
{ to: "/reports", label: "Reports" },
];
export default function Layout() {

View File

@@ -0,0 +1,220 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
interface Summary {
period: { from: string | null; to: string | null };
totals: {
revenue: number;
subtotal: number;
tax: number;
discounts: number;
transactionCount: number;
};
byPaymentMethod: { method: string; revenue: number; count: number }[];
topProducts: { productId: string; productName: string; revenue: number; unitsSold: number }[];
}
interface Transaction {
id: string;
status: string;
paymentMethod: string;
total: number;
taxTotal: number;
discountTotal: number;
createdAt: string;
user: { name: string; email: string };
items: { productName: string; quantity: number; unitPrice: number; total: number }[];
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; totalPages: number }; }
export default function ReportsPage() {
const [tab, setTab] = useState<"summary" | "transactions">("summary");
const [summary, setSummary] = useState<Summary | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txTotal, setTxTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const loadSummary = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to + "T23:59:59").toISOString());
const s = await api.get<Summary>(`/transactions/reports/summary?${params}`);
setSummary(s);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [from, to]);
const loadTransactions = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to + "T23:59:59").toISOString());
const res = await api.get<ApiList<Transaction>>(`/transactions?limit=50&${params}`);
setTransactions(res.data);
setTxTotal(res.pagination.total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => {
if (tab === "summary") loadSummary();
else loadTransactions();
}, [tab, loadSummary, loadTransactions]);
const txColumns = [
{ key: "createdAt", header: "Date", render: (t: Transaction) => new Date(t.createdAt).toLocaleString() },
{ key: "user", header: "Staff", render: (t: Transaction) => t.user.name },
{ key: "paymentMethod", header: "Payment", render: (t: Transaction) => <span style={methodBadge(t.paymentMethod)}>{t.paymentMethod}</span> },
{ key: "status", header: "Status", render: (t: Transaction) => <span style={statusBadge(t.status)}>{t.status}</span> },
{ key: "items", header: "Items", render: (t: Transaction) => t.items.length },
{ key: "total", header: "Total", render: (t: Transaction) => `$${t.total.toFixed(2)}` },
];
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Reports" subtitle="Sales and tax summaries" />
{/* Date range filter */}
<div style={filterRow}>
<label style={filterLabel}>From</label>
<input type="date" style={dateInput} value={from} onChange={(e) => setFrom(e.target.value)} />
<label style={filterLabel}>To</label>
<input type="date" style={dateInput} value={to} onChange={(e) => setTo(e.target.value)} />
<button type="button" style={applyBtn} onClick={() => tab === "summary" ? loadSummary() : loadTransactions()}>
Apply
</button>
{(from || to) && (
<button type="button" style={clearBtn} onClick={() => { setFrom(""); setTo(""); }}>
Clear
</button>
)}
</div>
{/* Tabs */}
<div style={tabs}>
{(["summary", "transactions"] as const).map((t) => (
<button key={t} type="button" style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "summary" && (
loading ? <div style={{ color: "var(--color-text-muted)" }}>Loading</div> :
summary && (
<div>
{/* Stat cards */}
<div style={statGrid}>
<StatCard label="Total Revenue" value={`$${summary.totals.revenue.toFixed(2)}`} />
<StatCard label="Transactions" value={String(summary.totals.transactionCount)} />
<StatCard label="Tax Collected" value={`$${summary.totals.tax.toFixed(2)}`} />
<StatCard label="Discounts Given" value={`$${summary.totals.discounts.toFixed(2)}`} />
</div>
<div style={twoCol}>
{/* Payment method breakdown */}
<div style={card}>
<div style={cardTitle}>By Payment Method</div>
{summary.byPaymentMethod.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No data</p>
: summary.byPaymentMethod.map((m) => (
<div key={m.method} style={methodRow}>
<span style={methodBadge(m.method)}>{m.method}</span>
<span style={{ marginLeft: "auto" }}>${m.revenue.toFixed(2)}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12, marginLeft: 8 }}>({m.count} txns)</span>
</div>
))
}
</div>
{/* Top products */}
<div style={card}>
<div style={cardTitle}>Top Products</div>
{summary.topProducts.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No data</p>
: summary.topProducts.map((p, i) => (
<div key={p.productId} style={productRow}>
<span style={rank}>{i + 1}</span>
<span style={{ flex: 1 }}>{p.productName}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>{p.unitsSold} sold</span>
<span style={{ fontWeight: 600, marginLeft: 12 }}>${p.revenue.toFixed(2)}</span>
</div>
))
}
</div>
</div>
</div>
)
)}
{tab === "transactions" && (
<div>
<div style={{ color: "var(--color-text-muted)", fontSize: 13, marginBottom: 12 }}>
{txTotal} transaction{txTotal !== 1 ? "s" : ""}
</div>
<Table columns={txColumns} data={transactions} keyField="id" loading={loading} emptyText="No transactions found." />
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={statCard}>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 26, fontWeight: 700 }}>{value}</div>
</div>
);
}
function methodBadge(method: string): React.CSSProperties {
return {
display: "inline-block", padding: "2px 10px", borderRadius: 999,
fontSize: 12, fontWeight: 600, textTransform: "capitalize",
background: method === "cash" ? "#f0fdf4" : "#eff6ff",
color: method === "cash" ? "#166534" : "#1e40af",
};
}
function statusBadge(status: string): React.CSSProperties {
const map: Record<string, { bg: string; color: string }> = {
completed: { bg: "#f0fdf4", color: "#166534" },
pending: { bg: "#fefce8", color: "#854d0e" },
failed: { bg: "#fef2f2", color: "#991b1b" },
refunded: { bg: "#f5f3ff", color: "#4c1d95" },
};
const c = map[status] ?? { bg: "#f1f5f9", color: "#475569" };
return { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, textTransform: "capitalize", ...c };
}
const filterRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, marginBottom: 20, flexWrap: "wrap" };
const filterLabel: React.CSSProperties = { fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" };
const dateInput: React.CSSProperties = { border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "6px 10px", fontSize: 13 };
const applyBtn: React.CSSProperties = { background: "var(--color-primary)", color: "#fff", border: "none", borderRadius: "var(--radius)", padding: "6px 14px", fontWeight: 600, fontSize: 13, cursor: "pointer" };
const clearBtn: React.CSSProperties = { background: "none", color: "var(--color-text-muted)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "6px 12px", fontSize: 13, cursor: "pointer" };
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)", paddingBottom: 0 };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };
const statGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 16, marginBottom: 24 };
const statCard: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "20px 24px", boxShadow: "var(--shadow)" };
const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 };
const card: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const cardTitle: React.CSSProperties = { fontWeight: 600, marginBottom: 12, fontSize: 14 };
const methodRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "8px 0", borderBottom: "1px solid var(--color-border)" };
const productRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: "1px solid var(--color-border)" };
const rank: React.CSSProperties = { width: 20, height: 20, borderRadius: "50%", background: "#e2e8f0", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };