home page functional
This commit is contained in:
parent
042f874437
commit
4bf370d47d
10 changed files with 551 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
apps/backend/src/trpc/pharmanager/v1/dashboard.ts
Normal file
9
apps/backend/src/trpc/pharmanager/v1/dashboard.ts
Normal 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)),
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
</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)}`}>
|
||||
• {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">• 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">• 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
231
packages/data-manager-sqlite/src/dashboard.ts
Normal file
231
packages/data-manager-sqlite/src/dashboard.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
5
packages/shared-react/src/hooks/dashboard.ts
Normal file
5
packages/shared-react/src/hooks/dashboard.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { trpc } from "../trpc";
|
||||
|
||||
export function useDashboard() {
|
||||
return trpc.dashboard.getStats.useQuery();
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
35
packages/shared/src/schemas/dashboard.ts
Normal file
35
packages/shared/src/schemas/dashboard.ts
Normal 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>;
|
||||
Loading…
Add table
Reference in a new issue