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:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
220
client/src/pages/ReportsPage.tsx
Normal file
220
client/src/pages/ReportsPage.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user