diff --git a/apps/backend/src/lib/data-manager.ts b/apps/backend/src/lib/data-manager.ts index 3ea2edf..5bf04ac 100644 --- a/apps/backend/src/lib/data-manager.ts +++ b/apps/backend/src/lib/data-manager.ts @@ -11,6 +11,7 @@ import { createRolesRepo, createCustomersRepo, createBillsRepo, + createDashboardRepo, type StorageSpacesRepo, type DistributorsRepo, type ProductsRepo, @@ -23,6 +24,7 @@ import { type RolesRepo, type CustomersRepo, type BillsRepo, + type DashboardRepo, } from "data-manager-sqlite"; export class DataManager { @@ -38,6 +40,7 @@ export class DataManager { readonly roles: RolesRepo; readonly customers: CustomersRepo; readonly bills: BillsRepo; + readonly dashboard: DashboardRepo; constructor() { const { repo: storageSpacesRepo } = createStorageSpacesRepo(); @@ -52,6 +55,7 @@ export class DataManager { const { repo: rolesRepo } = createRolesRepo(); const { repo: customersRepo } = createCustomersRepo(); const { repo: billsRepo } = createBillsRepo(); + const { repo: dashboardRepo } = createDashboardRepo(); this.storageSpaces = storageSpacesRepo; this.distributors = distributorsRepo; @@ -65,5 +69,6 @@ export class DataManager { this.roles = rolesRepo; this.customers = customersRepo; this.bills = billsRepo; + this.dashboard = dashboardRepo; } } diff --git a/apps/backend/src/trpc/pharmanager/v1/dashboard.ts b/apps/backend/src/trpc/pharmanager/v1/dashboard.ts new file mode 100644 index 0000000..fc06784 --- /dev/null +++ b/apps/backend/src/trpc/pharmanager/v1/dashboard.ts @@ -0,0 +1,9 @@ +import { protectedProcedure, router } from "../../init"; +import { dataManager } from "../../../lib/data-manager-instance"; +import { DashboardStatsSchema } from "@repo/shared"; + +export const dashboardRouter = router({ + getStats: protectedProcedure + .output(DashboardStatsSchema) + .query(({ ctx }) => dataManager.dashboard.getStats(ctx.staff.enterpriseId)), +}); diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index b5abf87..7fbba72 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -10,6 +10,7 @@ import { staffManagementRouter } from "./pharmanager/v1/staffManagement"; import { rolesRouter } from "./pharmanager/v1/roles"; import { billingRouter } from "./pharmanager/v1/billing"; import { customersRouter } from "./pharmanager/v1/customers"; +import { dashboardRouter } from "./pharmanager/v1/dashboard"; export const appRouter = router({ storage: storageRouter, @@ -23,6 +24,7 @@ export const appRouter = router({ roles: rolesRouter, billing: billingRouter, customers: customersRouter, + dashboard: dashboardRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/pharmanager/src/routes/index.tsx b/apps/pharmanager/src/routes/index.tsx index a96e587..d66d09b 100644 --- a/apps/pharmanager/src/routes/index.tsx +++ b/apps/pharmanager/src/routes/index.tsx @@ -1,4 +1,16 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { + Pill, + AlertTriangle, + DollarSign, + Clock, + Package, + ShoppingCart, + Users, + Plus, + ChevronRight, +} from "lucide-react"; +import { useDashboard } from "shared-react"; export const Route = createFileRoute("/")({ component: HomePage, @@ -8,6 +20,248 @@ export const Route = createFileRoute("/")({ }, }); -function HomePage() { - return
Home
; +function statStatusClass(qty: number, reorderLevel: number) { + if (qty <= 0) return "bg-red-50 text-red-600 border-red-200"; + if (qty <= reorderLevel * 0.5) return "bg-red-50 text-red-600 border-red-200"; + if (qty <= reorderLevel) return "bg-amber-50 text-amber-600 border-amber-200"; + return "bg-emerald-50 text-emerald-600 border-emerald-200"; +} + +function statStatusLabel(qty: number, reorderLevel: number) { + if (qty <= 0) return "Critical"; + if (qty <= reorderLevel * 0.5) return "Critical"; + if (qty <= reorderLevel) return "Low Stock"; + return "In Stock"; +} + +function HomePage() { + const { data, isLoading } = useDashboard(); + + if (isLoading || !data) { + return
Loading dashboard...
; + } + + return ( +
+ {/* Stats Row */} +
+
+
+ +
+
+
{data.totalProducts}
+
Total Medicines
+
+
+ +
+
+ +
+
+
{data.lowStockProducts}
+
Low Stock Items
+
+
+ +
+
+ +
+
+
₹{data.todaySales.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
Today's Sales
+
+
+ +
+
+ +
+
+
{data.expiringBatches}
+
Expiring Soon
+
+
+
+ + {/* Inventory Table */} +
+
+ Medicine Inventory + + View All → + +
+
+ + + + + + + + + + + + {data.recentProducts.map((p) => ( + + + + + + + + ))} + +
MedicineCategoryQuantityPriceStatus
+ + {p.name} + +
{p.brand}
+
{p.category} + {p.stock} + ₹{p.selling_price.toFixed(2)} + + • {statStatusLabel(p.stock, p.reorder_level)} + +
+
+
+ + {/* Widgets Row */} +
+ {/* Billing Summary */} +
+
+ Billing Summary +
+
₹{data.monthlyRevenue.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
Monthly Revenue
+
+
+ Today + ₹{data.todaySales.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ This Week + ₹{data.weeklyRevenue.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ Today Bills + {data.todayBillsCount} +
+
+ Total Invoices + {data.totalInvoices} +
+
+ + View Billing Details + + +
+ + {/* Stock Overview */} +
+
+ Stock Overview +
+
{data.totalStockUnits.toLocaleString()}
+
Total Units in Stock
+
+
+ Active Medicines + {data.totalProducts} +
+
+ • Low Stock + {data.lowStockProducts} +
+
+ • Critical + {data.criticalStock} +
+
+ Out of Stock + {data.outOfStock} +
+
+ + View All Batches + + +
+ + {/* Product Categories */} +
+
+ Product Categories +
+
{data.categoryBreakdown.length}
+
Medicine Categories
+
+ {data.categoryBreakdown.map((c) => ( +
+ {c.category} + {c.count} +
+ ))} +
+ + Browse Products + + +
+ + {/* Quick Actions */} +
+
+ Quick Actions +
+
+ + + Add New Medicine + + + + Add Stock Batch + + + + New Bill + + + + Customer List + +
+
+
+
+ ); } diff --git a/packages/data-manager-sqlite/src/dashboard.ts b/packages/data-manager-sqlite/src/dashboard.ts new file mode 100644 index 0000000..4015ed0 --- /dev/null +++ b/packages/data-manager-sqlite/src/dashboard.ts @@ -0,0 +1,231 @@ +import { count, sum, eq, and, gte, lt, lte } from 'drizzle-orm' +import { db } from './db-instance' +import { products } from './schema/products' +import { bills } from './schema/bills' +import { stockBatches } from './schema/stockBatches' +import { units } from './schema/units' + +export interface DashboardStats { + totalProducts: number + lowStockProducts: number + todaySales: number + monthlyRevenue: number + weeklyRevenue: number + todayBillsCount: number + totalInvoices: number + totalStockUnits: number + outOfStock: number + criticalStock: number + expiringBatches: number + categoryBreakdown: { category: string; count: number }[] + recentProducts: { + id: number + name: string + brand: string + category: string + selling_price: number + stock: number + reorder_level: number + unit_name: string + }[] +} + +export type DashboardRepo = { + getStats: (enterpriseId: number) => Promise +} + +function todayStr(): string { + return new Date().toISOString().slice(0, 10) +} + +function daysLater(n: number): string { + const d = new Date() + d.setDate(d.getDate() + n) + return d.toISOString().slice(0, 10) +} + +function monthStartStr(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01` +} + +function nextMonthStartStr(): string { + const d = new Date() + const next = new Date(d.getFullYear(), d.getMonth() + 1, 1) + return next.toISOString().slice(0, 10) +} + +function weekStartStr(): string { + const d = new Date() + const day = d.getDay() + const diff = d.getDate() - day + (day === 0 ? -6 : 1) + const monday = new Date(d.getFullYear(), d.getMonth(), diff) + return monday.toISOString().slice(0, 10) +} + +export function createDashboardRepo(): { repo: DashboardRepo } { + const repo: DashboardRepo = { + getStats(enterpriseId) { + const today = todayStr() + const tomorrow = daysLater(1) + const monthStart = monthStartStr() + const nextMonthStart = nextMonthStartStr() + const weekStart = weekStartStr() + const thirtyDays = daysLater(30) + + const totalProducts = db + .select({ count: count() }) + .from(products) + .where(eq(products.enterpriseId, enterpriseId)) + .get()! + + const todaySales = db + .select({ total: sum(bills.total) }) + .from(bills) + .where( + and( + eq(bills.enterpriseId, enterpriseId), + gte(bills.createdAt, today), + lt(bills.createdAt, tomorrow), + ), + ) + .get()! + + const monthlyRevenue = db + .select({ total: sum(bills.total) }) + .from(bills) + .where( + and( + eq(bills.enterpriseId, enterpriseId), + gte(bills.createdAt, monthStart), + lt(bills.createdAt, nextMonthStart), + ), + ) + .get()! + + const weeklyRevenue = db + .select({ total: sum(bills.total) }) + .from(bills) + .where( + and( + eq(bills.enterpriseId, enterpriseId), + gte(bills.createdAt, weekStart), + ), + ) + .get()! + + const todayBillsCount = db + .select({ count: count() }) + .from(bills) + .where( + and( + eq(bills.enterpriseId, enterpriseId), + gte(bills.createdAt, today), + lt(bills.createdAt, tomorrow), + ), + ) + .get()! + + const totalInvoices = db + .select({ count: count() }) + .from(bills) + .where(eq(bills.enterpriseId, enterpriseId)) + .get()! + + const totalStockUnits = db + .select({ total: sum(stockBatches.quantity) }) + .from(stockBatches) + .where(eq(stockBatches.enterpriseId, enterpriseId)) + .get()! + + const expiringBatches = db + .select({ count: count() }) + .from(stockBatches) + .where( + and( + eq(stockBatches.enterpriseId, enterpriseId), + gte(stockBatches.expiry, today), + lte(stockBatches.expiry, thirtyDays), + ), + ) + .get()! + + const categoryBreakdown = db + .select({ category: products.category, count: count() }) + .from(products) + .where(eq(products.enterpriseId, enterpriseId)) + .groupBy(products.category) + .all() + + const allProducts = db + .select() + .from(products) + .innerJoin(units, eq(units.id, products.unitId)) + .where(eq(products.enterpriseId, enterpriseId)) + .all() + + const stockRows = db + .select({ productId: stockBatches.productId, totalStock: sum(stockBatches.quantity) }) + .from(stockBatches) + .where(eq(stockBatches.enterpriseId, enterpriseId)) + .groupBy(stockBatches.productId) + .all() + + const stockMap = new Map() + for (const row of stockRows) { + stockMap.set(row.productId, Number(row.totalStock) || 0) + } + + let lowStockProducts = 0 + let outOfStock = 0 + let criticalStock = 0 + + const recentProducts: DashboardStats['recentProducts'] = [] + + for (const row of allProducts) { + const product = row.products + const unit = row.units + const stock = stockMap.get(product.id) ?? 0 + + if (stock <= 0) { + outOfStock++ + } else if (stock <= product.reorderLevel * 0.5) { + criticalStock++ + } else if (stock <= product.reorderLevel) { + lowStockProducts++ + } + + recentProducts.push({ + id: product.id, + name: product.name, + brand: product.brand, + category: product.category, + selling_price: product.sellingPrice, + stock, + reorder_level: product.reorderLevel, + unit_name: unit.name, + }) + } + + recentProducts.sort((a, b) => a.stock - b.stock || a.name.localeCompare(b.name)) + + return Promise.resolve({ + totalProducts: Number(totalProducts.count), + lowStockProducts, + todaySales: Number(todaySales.total) || 0, + monthlyRevenue: Number(monthlyRevenue.total) || 0, + weeklyRevenue: Number(weeklyRevenue.total) || 0, + todayBillsCount: Number(todayBillsCount.count), + totalInvoices: Number(totalInvoices.count), + totalStockUnits: Number(totalStockUnits.total) || 0, + outOfStock, + criticalStock, + expiringBatches: Number(expiringBatches.count), + categoryBreakdown, + recentProducts: recentProducts.slice(0, 10), + }) + }, + } + + return { repo } +} diff --git a/packages/data-manager-sqlite/src/index.ts b/packages/data-manager-sqlite/src/index.ts index de8c918..03b9efa 100644 --- a/packages/data-manager-sqlite/src/index.ts +++ b/packages/data-manager-sqlite/src/index.ts @@ -91,3 +91,8 @@ export { export { customers } from './schema/customers' export { bills } from './schema/bills' export { billItems } from './schema/billItems' +export { + createDashboardRepo, + type DashboardStats, + type DashboardRepo, +} from './dashboard' diff --git a/packages/shared-react/src/hooks/dashboard.ts b/packages/shared-react/src/hooks/dashboard.ts new file mode 100644 index 0000000..2abb3c3 --- /dev/null +++ b/packages/shared-react/src/hooks/dashboard.ts @@ -0,0 +1,5 @@ +import { trpc } from "../trpc"; + +export function useDashboard() { + return trpc.dashboard.getStats.useQuery(); +} diff --git a/packages/shared-react/src/index.ts b/packages/shared-react/src/index.ts index 7140826..791a2f3 100644 --- a/packages/shared-react/src/index.ts +++ b/packages/shared-react/src/index.ts @@ -11,6 +11,7 @@ export * from './hooks/staffManagement' export * from './hooks/roles' export * from './hooks/billing' export * from './hooks/customers' +export * from './hooks/dashboard' export { trpc } from './trpc' export { useAuthStore, useWhoAmI, useLogin } from './auth' export type { AuthStaff, AuthEnterprise } from './auth' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 31e6aae..6f714a3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,6 +20,7 @@ export type { // Shared schemas export * from './schemas/product' export * from './schemas/stock' +export * from './schemas/dashboard' // Global constants export { globalConsts } from './global-consts' diff --git a/packages/shared/src/schemas/dashboard.ts b/packages/shared/src/schemas/dashboard.ts new file mode 100644 index 0000000..252da29 --- /dev/null +++ b/packages/shared/src/schemas/dashboard.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const DashboardStatsSchema = z.object({ + totalProducts: z.number().int(), + lowStockProducts: z.number().int(), + todaySales: z.number(), + monthlyRevenue: z.number(), + weeklyRevenue: z.number(), + todayBillsCount: z.number().int(), + totalInvoices: z.number().int(), + totalStockUnits: z.number().int(), + outOfStock: z.number().int(), + criticalStock: z.number().int(), + expiringBatches: z.number().int(), + categoryBreakdown: z.array( + z.object({ + category: z.string(), + count: z.number().int(), + }), + ), + recentProducts: z.array( + z.object({ + id: z.number().int(), + name: z.string(), + brand: z.string(), + category: z.string(), + selling_price: z.number(), + stock: z.number().int(), + reorder_level: z.number().int(), + unit_name: z.string(), + }), + ), +}); + +export type DashboardStats = z.infer;