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,
|
createRolesRepo,
|
||||||
createCustomersRepo,
|
createCustomersRepo,
|
||||||
createBillsRepo,
|
createBillsRepo,
|
||||||
|
createDashboardRepo,
|
||||||
type StorageSpacesRepo,
|
type StorageSpacesRepo,
|
||||||
type DistributorsRepo,
|
type DistributorsRepo,
|
||||||
type ProductsRepo,
|
type ProductsRepo,
|
||||||
|
|
@ -23,6 +24,7 @@ import {
|
||||||
type RolesRepo,
|
type RolesRepo,
|
||||||
type CustomersRepo,
|
type CustomersRepo,
|
||||||
type BillsRepo,
|
type BillsRepo,
|
||||||
|
type DashboardRepo,
|
||||||
} from "data-manager-sqlite";
|
} from "data-manager-sqlite";
|
||||||
|
|
||||||
export class DataManager {
|
export class DataManager {
|
||||||
|
|
@ -38,6 +40,7 @@ export class DataManager {
|
||||||
readonly roles: RolesRepo;
|
readonly roles: RolesRepo;
|
||||||
readonly customers: CustomersRepo;
|
readonly customers: CustomersRepo;
|
||||||
readonly bills: BillsRepo;
|
readonly bills: BillsRepo;
|
||||||
|
readonly dashboard: DashboardRepo;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const { repo: storageSpacesRepo } = createStorageSpacesRepo();
|
const { repo: storageSpacesRepo } = createStorageSpacesRepo();
|
||||||
|
|
@ -52,6 +55,7 @@ export class DataManager {
|
||||||
const { repo: rolesRepo } = createRolesRepo();
|
const { repo: rolesRepo } = createRolesRepo();
|
||||||
const { repo: customersRepo } = createCustomersRepo();
|
const { repo: customersRepo } = createCustomersRepo();
|
||||||
const { repo: billsRepo } = createBillsRepo();
|
const { repo: billsRepo } = createBillsRepo();
|
||||||
|
const { repo: dashboardRepo } = createDashboardRepo();
|
||||||
|
|
||||||
this.storageSpaces = storageSpacesRepo;
|
this.storageSpaces = storageSpacesRepo;
|
||||||
this.distributors = distributorsRepo;
|
this.distributors = distributorsRepo;
|
||||||
|
|
@ -65,5 +69,6 @@ export class DataManager {
|
||||||
this.roles = rolesRepo;
|
this.roles = rolesRepo;
|
||||||
this.customers = customersRepo;
|
this.customers = customersRepo;
|
||||||
this.bills = billsRepo;
|
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 { rolesRouter } from "./pharmanager/v1/roles";
|
||||||
import { billingRouter } from "./pharmanager/v1/billing";
|
import { billingRouter } from "./pharmanager/v1/billing";
|
||||||
import { customersRouter } from "./pharmanager/v1/customers";
|
import { customersRouter } from "./pharmanager/v1/customers";
|
||||||
|
import { dashboardRouter } from "./pharmanager/v1/dashboard";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
storage: storageRouter,
|
storage: storageRouter,
|
||||||
|
|
@ -23,6 +24,7 @@ export const appRouter = router({
|
||||||
roles: rolesRouter,
|
roles: rolesRouter,
|
||||||
billing: billingRouter,
|
billing: billingRouter,
|
||||||
customers: customersRouter,
|
customers: customersRouter,
|
||||||
|
dashboard: dashboardRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
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("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: HomePage,
|
component: HomePage,
|
||||||
|
|
@ -8,6 +20,248 @@ export const Route = createFileRoute("/")({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function HomePage() {
|
function statStatusClass(qty: number, reorderLevel: number) {
|
||||||
return <div>Home</div>;
|
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 { customers } from './schema/customers'
|
||||||
export { bills } from './schema/bills'
|
export { bills } from './schema/bills'
|
||||||
export { billItems } from './schema/billItems'
|
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/roles'
|
||||||
export * from './hooks/billing'
|
export * from './hooks/billing'
|
||||||
export * from './hooks/customers'
|
export * from './hooks/customers'
|
||||||
|
export * from './hooks/dashboard'
|
||||||
export { trpc } from './trpc'
|
export { trpc } from './trpc'
|
||||||
export { useAuthStore, useWhoAmI, useLogin } from './auth'
|
export { useAuthStore, useWhoAmI, useLogin } from './auth'
|
||||||
export type { AuthStaff, AuthEnterprise } from './auth'
|
export type { AuthStaff, AuthEnterprise } from './auth'
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export type {
|
||||||
// Shared schemas
|
// Shared schemas
|
||||||
export * from './schemas/product'
|
export * from './schemas/product'
|
||||||
export * from './schemas/stock'
|
export * from './schemas/stock'
|
||||||
|
export * from './schemas/dashboard'
|
||||||
|
|
||||||
// Global constants
|
// Global constants
|
||||||
export { globalConsts } from './global-consts'
|
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