From 91e1a1ffbfba8bdc1cc4ac4236923ec56d162d89 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 21 Mar 2026 06:51:27 -0500 Subject: [PATCH] Milestone 3: catalog sync, batch transactions, and reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 14 +- client/src/App.tsx | 2 + client/src/components/Layout.tsx | 1 + client/src/pages/ReportsPage.tsx | 220 +++++++++++++++++++++++++ server/src/app.ts | 4 + server/src/routes/catalog.ts | 66 ++++++++ server/src/routes/transactions.ts | 261 ++++++++++++++++++++++++++++++ 7 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/ReportsPage.tsx create mode 100644 server/src/routes/catalog.ts create mode 100644 server/src/routes/transactions.ts diff --git a/ROADMAP.md b/ROADMAP.md index 6938348..a1166e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,13 +25,15 @@ --- -## Milestone 3 — Android & Offline Sync -- [ ] `GET /api/v1/catalog/sync?since=` for delta syncs -- [ ] `POST /api/v1/transactions/batch` with idempotency keys -- [ ] Android Kotlin app: MVVM, Room, offline-first flows +## Milestone 3 — Android & Offline Sync ✅ (server-side) +- [x] `GET /api/v1/catalog/sync?since=` — delta sync for products, categories, taxes +- [x] `POST /api/v1/transactions/batch` — idempotency-keyed batch upload (207 Multi-Status) +- [x] `GET /api/v1/transactions` — paginated list with date/status/payment filters +- [x] `GET /api/v1/transactions/reports/summary` — revenue, tax, top products, payment breakdown +- [x] Reports page: stat cards, payment method breakdown, top products, transaction table +- [ ] Android Kotlin app: MVVM, Room, offline-first flows (separate deliverable) - [ ] Background sync worker (Android) -- [ ] Conflict resolution strategy: server-authoritative for payments -- [ ] Admin reporting: Android-originated transactions appear in reports +- [ ] Conflict resolution: server-authoritative for payments (enforced via idempotency) --- diff --git a/client/src/App.tsx b/client/src/App.tsx index 60c4fae..c6eb1ce 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 77be44b..1f1cba3 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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() { diff --git a/client/src/pages/ReportsPage.tsx b/client/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..d4b6318 --- /dev/null +++ b/client/src/pages/ReportsPage.tsx @@ -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 { data: T[]; pagination: { total: number; page: number; totalPages: number }; } + +export default function ReportsPage() { + const [tab, setTab] = useState<"summary" | "transactions">("summary"); + const [summary, setSummary] = useState(null); + const [transactions, setTransactions] = useState([]); + 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(`/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>(`/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) => {t.paymentMethod} }, + { key: "status", header: "Status", render: (t: Transaction) => {t.status} }, + { key: "items", header: "Items", render: (t: Transaction) => t.items.length }, + { key: "total", header: "Total", render: (t: Transaction) => `$${t.total.toFixed(2)}` }, + ]; + + return ( +
+ + + {/* Date range filter */} +
+ + setFrom(e.target.value)} /> + + setTo(e.target.value)} /> + + {(from || to) && ( + + )} +
+ + {/* Tabs */} +
+ {(["summary", "transactions"] as const).map((t) => ( + + ))} +
+ + {tab === "summary" && ( + loading ?
Loading…
: + summary && ( +
+ {/* Stat cards */} +
+ + + + +
+ +
+ {/* Payment method breakdown */} +
+
By Payment Method
+ {summary.byPaymentMethod.length === 0 + ?

No data

+ : summary.byPaymentMethod.map((m) => ( +
+ {m.method} + ${m.revenue.toFixed(2)} + ({m.count} txns) +
+ )) + } +
+ + {/* Top products */} +
+
Top Products
+ {summary.topProducts.length === 0 + ?

No data

+ : summary.topProducts.map((p, i) => ( +
+ {i + 1} + {p.productName} + {p.unitsSold} sold + ${p.revenue.toFixed(2)} +
+ )) + } +
+
+
+ ) + )} + + {tab === "transactions" && ( +
+
+ {txTotal} transaction{txTotal !== 1 ? "s" : ""} +
+ + + )} + + ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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 = { + 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 }; diff --git a/server/src/app.ts b/server/src/app.ts index dc6412b..dd9c864 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,6 +9,8 @@ import usersRouter from "./routes/users.js"; import categoriesRouter from "./routes/categories.js"; import taxesRouter from "./routes/taxes.js"; import productsRouter from "./routes/products.js"; +import catalogRouter from "./routes/catalog.js"; +import transactionsRouter from "./routes/transactions.js"; import { errorHandler } from "./middleware/errorHandler.js"; const __filename = fileURLToPath(import.meta.url); @@ -34,6 +36,8 @@ export function createApp() { app.use("/api/v1/categories", categoriesRouter); app.use("/api/v1/taxes", taxesRouter); app.use("/api/v1/products", productsRouter); + app.use("/api/v1/catalog", catalogRouter); + app.use("/api/v1/transactions", transactionsRouter); // Serve React admin UI static assets in production if (process.env.NODE_ENV === "production") { diff --git a/server/src/routes/catalog.ts b/server/src/routes/catalog.ts new file mode 100644 index 0000000..1e6eaa7 --- /dev/null +++ b/server/src/routes/catalog.ts @@ -0,0 +1,66 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { prisma } from "../lib/prisma.js"; +import { requireAuth } from "../middleware/auth.js"; +import { AuthenticatedRequest } from "../types/index.js"; + +const router = Router(); +const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void; + +/** + * GET /api/v1/catalog/sync?since= + * + * Delta-sync endpoint for Android offline-first client. + * Returns all catalog entities updated after `since` (or all if omitted). + * Response is versioned so Android can detect stale caches. + */ +router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const { vendorId } = authReq.auth; + + const sinceRaw = req.query.since as string | undefined; + const since = sinceRaw ? new Date(sinceRaw) : undefined; + + if (since && isNaN(since.getTime())) { + res.status(400).json({ + error: { code: "BAD_REQUEST", message: "Invalid `since` date" }, + }); + return; + } + + const updatedAfter = since ? { updatedAt: { gt: since } } : {}; + + const [products, categories, taxes] = await Promise.all([ + prisma.product.findMany({ + where: { vendorId, ...updatedAfter }, + include: { category: true, tax: true }, + orderBy: { updatedAt: "asc" }, + }), + prisma.category.findMany({ + where: { vendorId, ...updatedAfter }, + orderBy: { updatedAt: "asc" }, + }), + prisma.tax.findMany({ + where: { vendorId, ...updatedAfter }, + orderBy: { updatedAt: "asc" }, + }), + ]); + + res.json({ + syncedAt: new Date().toISOString(), + since: since?.toISOString() ?? null, + products, + categories, + taxes, + counts: { + products: products.length, + categories: categories.length, + taxes: taxes.length, + }, + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/transactions.ts b/server/src/routes/transactions.ts new file mode 100644 index 0000000..a79b2db --- /dev/null +++ b/server/src/routes/transactions.ts @@ -0,0 +1,261 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { prisma } from "../lib/prisma.js"; +import { requireAuth, requireRole } from "../middleware/auth.js"; +import { AppError } from "../middleware/errorHandler.js"; +import { parsePage, paginatedResponse } from "../lib/pagination.js"; +import { AuthenticatedRequest } from "../types/index.js"; + +const router = Router(); +const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void; +const managerUp = requireRole("owner", "manager") as unknown as (r: Request, s: Response, n: NextFunction) => void; + +// ─── Schemas ────────────────────────────────────────────────────────────── + +const TransactionItemSchema = z.object({ + productId: z.string().min(1), + productName: z.string().min(1), + quantity: z.number().int().positive(), + unitPrice: z.number().min(0), + taxRate: z.number().min(0).max(100), + discount: z.number().min(0).default(0), + total: z.number().min(0), +}); + +const TransactionSchema = z.object({ + idempotencyKey: z.string().min(1).max(200), + status: z.enum(["pending", "completed", "failed", "refunded"]), + paymentMethod: z.enum(["cash", "card"]), + subtotal: z.number().min(0), + taxTotal: z.number().min(0), + discountTotal: z.number().min(0), + total: z.number().min(0), + notes: z.string().max(500).optional(), + items: z.array(TransactionItemSchema).min(1), + // Android includes a local timestamp for ordering + createdAt: z.string().datetime().optional(), +}); + +const BatchSchema = z.object({ + transactions: z.array(TransactionSchema).min(1).max(500), +}); + +// ─── POST /api/v1/transactions/batch ────────────────────────────────────── +// Android pushes locally-recorded transactions. Server is authoritative on +// payments — duplicate idempotency keys are silently skipped (already processed). + +router.post("/batch", auth, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const { vendorId, userId } = authReq.auth; + const { transactions } = BatchSchema.parse(req.body); + + const results: Array<{ idempotencyKey: string; status: "created" | "duplicate" | "error"; id?: string; error?: string }> = []; + + for (const tx of transactions) { + try { + // Check for existing transaction with same idempotency key + const existing = await prisma.transaction.findUnique({ + where: { idempotencyKey: tx.idempotencyKey }, + select: { id: true }, + }); + + if (existing) { + results.push({ idempotencyKey: tx.idempotencyKey, status: "duplicate", id: existing.id }); + continue; + } + + // Validate all product IDs belong to this vendor + const productIds = tx.items.map((i) => i.productId); + const products = await prisma.product.findMany({ + where: { id: { in: productIds }, vendorId }, + select: { id: true }, + }); + const validIds = new Set(products.map((p) => p.id)); + const invalidIds = productIds.filter((id) => !validIds.has(id)); + if (invalidIds.length > 0) { + results.push({ + idempotencyKey: tx.idempotencyKey, + status: "error", + error: `Invalid product IDs: ${invalidIds.join(", ")}`, + }); + continue; + } + + const created = await prisma.transaction.create({ + data: { + idempotencyKey: tx.idempotencyKey, + vendorId, + userId, + status: tx.status, + paymentMethod: tx.paymentMethod, + subtotal: tx.subtotal, + taxTotal: tx.taxTotal, + discountTotal: tx.discountTotal, + total: tx.total, + notes: tx.notes, + ...(tx.createdAt ? { createdAt: new Date(tx.createdAt) } : {}), + items: { + create: tx.items.map((item) => ({ + productId: item.productId, + productName: item.productName, + quantity: item.quantity, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + discount: item.discount, + total: item.total, + })), + }, + }, + select: { id: true }, + }); + + results.push({ idempotencyKey: tx.idempotencyKey, status: "created", id: created.id }); + } catch (err) { + results.push({ + idempotencyKey: tx.idempotencyKey, + status: "error", + error: err instanceof Error ? err.message : "Unknown error", + }); + } + } + + const created = results.filter((r) => r.status === "created").length; + const duplicates = results.filter((r) => r.status === "duplicate").length; + const errors = results.filter((r) => r.status === "error").length; + + res.status(207).json({ results, summary: { created, duplicates, errors } }); + } catch (err) { + next(err); + } +}); + +// ─── GET /api/v1/transactions ────────────────────────────────────────────── + +router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const { vendorId } = authReq.auth; + const { page, limit, skip } = parsePage(req.query as Record); + const { status, paymentMethod, from, to } = req.query as Record; + + const where = { + vendorId, + ...(status ? { status } : {}), + ...(paymentMethod ? { paymentMethod } : {}), + ...(from || to + ? { + createdAt: { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }, + } + : {}), + }; + + const [data, total] = await Promise.all([ + prisma.transaction.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: "desc" }, + include: { user: { select: { id: true, name: true, email: true } }, items: true }, + }), + prisma.transaction.count({ where }), + ]); + + res.json(paginatedResponse(data, total, { page, limit, skip })); + } catch (err) { + next(err); + } +}); + +// ─── GET /api/v1/transactions/:id ───────────────────────────────────────── + +router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const tx = await prisma.transaction.findFirst({ + where: { id: req.params.id, vendorId: authReq.auth.vendorId }, + include: { + user: { select: { id: true, name: true, email: true } }, + items: true, + }, + }); + if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found"); + res.json(tx); + } catch (err) { + next(err); + } +}); + +// ─── GET /api/v1/transactions/reports/summary ───────────────────────────── +// Daily totals, payment method breakdown, top products. + +router.get("/reports/summary", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const { vendorId } = authReq.auth; + const { from, to } = req.query as { from?: string; to?: string }; + + const dateFilter = { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }; + const where = { + vendorId, + status: "completed", + ...(from || to ? { createdAt: dateFilter } : {}), + }; + + const [totals, byPayment, topProducts] = await Promise.all([ + // Overall totals + prisma.transaction.aggregate({ + where, + _sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true }, + _count: { id: true }, + }), + // Breakdown by payment method + prisma.transaction.groupBy({ + by: ["paymentMethod"], + where, + _sum: { total: true }, + _count: { id: true }, + }), + // Top 10 products by revenue + prisma.transactionItem.groupBy({ + by: ["productId", "productName"], + where: { transaction: where }, + _sum: { total: true, quantity: true }, + orderBy: { _sum: { total: "desc" } }, + take: 10, + }), + ]); + + res.json({ + period: { from: from ?? null, to: to ?? null }, + totals: { + revenue: totals._sum.total ?? 0, + subtotal: totals._sum.subtotal ?? 0, + tax: totals._sum.taxTotal ?? 0, + discounts: totals._sum.discountTotal ?? 0, + transactionCount: totals._count.id, + }, + byPaymentMethod: byPayment.map((r) => ({ + method: r.paymentMethod, + revenue: r._sum.total ?? 0, + count: r._count.id, + })), + topProducts: topProducts.map((r) => ({ + productId: r.productId, + productName: r.productName, + revenue: r._sum.total ?? 0, + unitsSold: r._sum.quantity ?? 0, + })), + }); + } catch (err) { + next(err); + } +}); + +export default router;