home page functional

This commit is contained in:
shafi54 2026-05-24 17:08:30 +05:30
parent 042f874437
commit 4bf370d47d
10 changed files with 551 additions and 3 deletions

View file

@ -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;
}
}

View file

@ -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)),
});

View file

@ -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;

View file

@ -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 <div>Home</div>;
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 <div className="text-sm text-slate-600 py-8">Loading dashboard...</div>;
}
return (
<div>
{/* Stats Row */}
<div className="grid grid-cols-4 gap-4 mb-6 max-[1024px]:grid-cols-2 max-[480px]:grid-cols-1">
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex items-start gap-4">
<div className="w-11 h-11 rounded-[10px] flex items-center justify-center flex-shrink-0 bg-blue-50 text-blue-600">
<Pill className="w-5 h-5" />
</div>
<div>
<div className="text-2xl font-bold leading-tight">{data.totalProducts}</div>
<div className="text-[13px] text-slate-500 mt-0.5">Total Medicines</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex items-start gap-4">
<div className="w-11 h-11 rounded-[10px] flex items-center justify-center flex-shrink-0 bg-amber-50 text-amber-600">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<div className="text-2xl font-bold leading-tight">{data.lowStockProducts}</div>
<div className="text-[13px] text-slate-500 mt-0.5">Low Stock Items</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex items-start gap-4">
<div className="w-11 h-11 rounded-[10px] flex items-center justify-center flex-shrink-0 bg-emerald-50 text-emerald-600">
<DollarSign className="w-5 h-5" />
</div>
<div>
<div className="text-2xl font-bold leading-tight">{data.todaySales.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="text-[13px] text-slate-500 mt-0.5">Today's Sales</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex items-start gap-4">
<div className="w-11 h-11 rounded-[10px] flex items-center justify-center flex-shrink-0 bg-red-50 text-red-600">
<Clock className="w-5 h-5" />
</div>
<div>
<div className="text-2xl font-bold leading-tight">{data.expiringBatches}</div>
<div className="text-[13px] text-slate-500 mt-0.5">Expiring Soon</div>
</div>
</div>
</div>
{/* Inventory Table */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200">
<span className="text-[15px] font-semibold">Medicine Inventory</span>
<Link to="/stock" className="text-[13px] text-blue-600 font-medium hover:underline">
View All &rarr;
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Medicine</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Category</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Quantity</th>
<th className="text-right px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Price</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody>
{data.recentProducts.map((p) => (
<tr key={p.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-4 py-3">
<Link
to="/products/$id"
params={{ id: p.id.toString() }}
className="font-semibold text-[13px] text-blue-600 hover:underline"
>
{p.name}
</Link>
<div className="text-xs text-slate-500">{p.brand}</div>
</td>
<td className="px-4 py-3 text-[14px]">{p.category}</td>
<td className={`px-4 py-3 text-center font-semibold ${
p.stock <= 0 ? "text-red-600"
: p.stock <= p.reorder_level * 0.5 ? "text-red-600"
: p.stock <= p.reorder_level ? "text-amber-600"
: "text-emerald-600"
}`}>
{p.stock}
</td>
<td className="px-4 py-3 text-right">{p.selling_price.toFixed(2)}</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border ${statStatusClass(p.stock, p.reorder_level)}`}>
&bull; {statStatusLabel(p.stock, p.reorder_level)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Widgets Row */}
<div className="grid grid-cols-4 gap-4 mb-6 max-[1200px]:grid-cols-2 max-[768px]:grid-cols-1">
{/* Billing Summary */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex flex-col">
<div className="pb-3 mb-3 border-b border-slate-200">
<span className="text-[15px] font-semibold">Billing Summary</span>
</div>
<div className="text-[28px] font-bold leading-tight">{data.monthlyRevenue.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="text-xs text-slate-500 mt-0.5">Monthly Revenue</div>
<div className="mt-3">
<div className="flex justify-between items-center py-1.5 text-[13px]">
<span className="text-slate-500">Today</span>
<span className="font-semibold">{data.todaySales.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-slate-500">This Week</span>
<span className="font-semibold">{data.weeklyRevenue.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-slate-500">Today Bills</span>
<span className="font-semibold">{data.todayBillsCount}</span>
</div>
<div className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-slate-500">Total Invoices</span>
<span className="font-semibold">{data.totalInvoices}</span>
</div>
</div>
<Link to="/billing" className="mt-auto pt-3 text-[13px] font-medium text-blue-600 flex items-center gap-1 hover:underline hover:gap-2 transition-all">
View Billing Details
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</div>
{/* Stock Overview */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex flex-col">
<div className="pb-3 mb-3 border-b border-slate-200">
<span className="text-[15px] font-semibold">Stock Overview</span>
</div>
<div className="text-[28px] font-bold leading-tight">{data.totalStockUnits.toLocaleString()}</div>
<div className="text-xs text-slate-500 mt-0.5">Total Units in Stock</div>
<div className="mt-3">
<div className="flex justify-between items-center py-1.5 text-[13px]">
<span className="text-slate-500">Active Medicines</span>
<span className="font-semibold">{data.totalProducts}</span>
</div>
<div className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-amber-600">&bull; Low Stock</span>
<span className="font-semibold text-amber-600">{data.lowStockProducts}</span>
</div>
<div className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-red-600">&bull; Critical</span>
<span className="font-semibold text-red-600">{data.criticalStock}</span>
</div>
<div className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-slate-500">Out of Stock</span>
<span className="font-semibold">{data.outOfStock}</span>
</div>
</div>
<Link to="/stock" className="mt-auto pt-3 text-[13px] font-medium text-blue-600 flex items-center gap-1 hover:underline hover:gap-2 transition-all">
View All Batches
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</div>
{/* Product Categories */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex flex-col">
<div className="pb-3 mb-3 border-b border-slate-200">
<span className="text-[15px] font-semibold">Product Categories</span>
</div>
<div className="text-[28px] font-bold leading-tight">{data.categoryBreakdown.length}</div>
<div className="text-xs text-slate-500 mt-0.5">Medicine Categories</div>
<div className="mt-3">
{data.categoryBreakdown.map((c) => (
<div key={c.category} className="flex justify-between items-center py-1.5 text-[13px] border-t border-slate-100">
<span className="text-slate-500">{c.category}</span>
<span className="font-semibold">{c.count}</span>
</div>
))}
</div>
<Link to="/products" className="mt-auto pt-3 text-[13px] font-medium text-blue-600 flex items-center gap-1 hover:underline hover:gap-2 transition-all">
Browse Products
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 flex flex-col">
<div className="pb-3 mb-3 border-b border-slate-200">
<span className="text-[15px] font-semibold">Quick Actions</span>
</div>
<div className="flex flex-col gap-2">
<Link
to="/products/add"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-[13px] font-medium text-slate-700 hover:bg-slate-50 transition-colors"
>
<Plus className="w-[18px] h-[18px] text-slate-500" />
Add New Medicine
</Link>
<Link
to="/stock/add"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-[13px] font-medium text-slate-700 hover:bg-slate-50 transition-colors"
>
<Package className="w-[18px] h-[18px] text-slate-500" />
Add Stock Batch
</Link>
<Link
to="/billing"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-[13px] font-medium text-slate-700 hover:bg-slate-50 transition-colors"
>
<ShoppingCart className="w-[18px] h-[18px] text-slate-500" />
New Bill
</Link>
<Link
to="/customers"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-[13px] font-medium text-slate-700 hover:bg-slate-50 transition-colors"
>
<Users className="w-[18px] h-[18px] text-slate-500" />
Customer List
</Link>
</div>
</div>
</div>
</div>
);
}

View file

@ -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<DashboardStats>
}
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<number, number>()
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 }
}

View file

@ -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'

View file

@ -0,0 +1,5 @@
import { trpc } from "../trpc";
export function useDashboard() {
return trpc.dashboard.getStats.useQuery();
}

View file

@ -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'

View file

@ -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'

View file

@ -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<typeof DashboardStatsSchema>;