feat(dashboard): role-specific dashboards for submitter, manager, accounts and admin

Submitter: open PO count, recent orders table, New PO CTA.
Manager: approvals count, approved PO listing, spend by vessel and month bar charts (Recharts).
Accounts: payment queue total value, ready-for-payment count.
Admin/Auditor: total PO count card.
This commit is contained in:
Hardik 2026-05-05 23:24:55 +05:30
parent 77aafcce99
commit 94774ca96b
2 changed files with 284 additions and 0 deletions

View file

@ -0,0 +1,261 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatCard } from "@/components/dashboard/stat-card";
import { SpendCharts } from "@/components/dashboard/spend-charts";
import { formatCurrency, formatDate, PO_STATUS_LABELS } from "@/lib/utils";
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Dashboard" };
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
const { role, id: userId } = session.user;
if (role === "TECHNICAL" || role === "MANNING" || role === "SUPERUSER") {
return <SubmitterDashboard userId={userId} />;
}
if (role === "MANAGER") {
return <ManagerDashboard />;
}
if (role === "ACCOUNTS") {
return <AccountsDashboard />;
}
return <GenericDashboard />;
}
async function SubmitterDashboard({ userId }: { userId: string }) {
const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([
db.purchaseOrder.count({
where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED"] } },
}),
db.purchaseOrder.count({
where: { submitterId: userId, status: "MGR_REVIEW" },
}),
db.purchaseOrder.count({
where: { submitterId: userId, status: "CLOSED" },
}),
db.purchaseOrder.findMany({
where: { submitterId: userId },
orderBy: { updatedAt: "desc" },
take: 8,
select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, updatedAt: true },
}),
]);
return (
<div>
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Open Orders" value={openCount} icon={FileText} color="blue" />
<StatCard label="Pending Approval" value={pendingCount} icon={Clock} color="orange" />
<StatCard label="Completed Orders" value={closedCount} icon={CheckCircle} color="green" />
</div>
<div className="mt-6 flex items-center gap-4">
<a
href="/po/new"
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ New Purchase Order
</a>
<Link href="/my-orders" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
View all my purchase orders
</Link>
</div>
{recentPos.length > 0 && (
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-700 mb-3">Recent Orders</h2>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{recentPos.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:underline">
{po.poNumber}
</Link>
</td>
<td className="px-4 py-3 text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3">
<span className="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">
{PO_STATUS_LABELS[po.status]}
</span>
</td>
<td className="px-4 py-3 text-right font-mono text-xs">{formatCurrency(Number(po.totalAmount))}</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(po.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
async function ManagerDashboard() {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED"] as const;
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
db.purchaseOrder.count({ where: { status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } } }),
db.purchaseOrder.aggregate({
_sum: { totalAmount: true },
where: { status: { in: [...approvedStatuses] } },
}),
db.purchaseOrder.findMany({
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED"] } },
orderBy: { approvedAt: "desc" },
take: 8,
select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } } },
}),
db.purchaseOrder.groupBy({
by: ["vesselId"],
_sum: { totalAmount: true },
where: { status: { in: [...approvedStatuses] } },
orderBy: { _sum: { totalAmount: "desc" } },
take: 5,
}),
db.purchaseOrder.findMany({
where: { status: { in: [...approvedStatuses] }, approvedAt: { gte: twelveMonthsAgo } },
select: { totalAmount: true, approvedAt: true },
}),
]);
const vesselIds = vesselBreakdown.map((r) => r.vesselId);
const vessels = await db.vessel.findMany({ where: { id: { in: vesselIds } }, select: { id: true, name: true } });
const vesselMap = Object.fromEntries(vessels.map((v) => [v.id, v.name]));
const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0);
// Build monthly series for last 12 months
const monthlyMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
monthlyMap[key] = 0;
}
for (const po of monthlyPos) {
if (!po.approvedAt) continue;
const key = `${po.approvedAt.getFullYear()}-${String(po.approvedAt.getMonth() + 1).padStart(2, "0")}`;
if (key in monthlyMap) monthlyMap[key] += Number(po.totalAmount);
}
const monthData = Object.entries(monthlyMap).map(([key, amount]) => {
const [year, month] = key.split("-");
const label = new Date(Number(year), Number(month) - 1, 1).toLocaleString("en", { month: "short", year: "2-digit" });
return { month: label, amount };
});
const vesselChartData = vesselBreakdown.map((row) => ({
name: vesselMap[row.vesselId] ?? "Unknown",
amount: Number(row._sum.totalAmount ?? 0),
}));
return (
<div>
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" />
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
</div>
{/* Recent approved POs */}
{recentApproved.length > 0 && (
<div className="mt-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-neutral-700">Recent Approved Orders</h2>
<Link href="/history" className="text-xs text-primary-600 hover:text-primary-700">View all </Link>
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vessel</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Approved</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{recentApproved.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:underline">
{po.poNumber}
</Link>
</td>
<td className="px-4 py-3 text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3">
<span className="rounded-full bg-success-100 px-2.5 py-0.5 text-xs font-medium text-success-700">
{PO_STATUS_LABELS[po.status]}
</span>
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-semibold">{formatCurrency(Number(po.totalAmount))}</td>
<td className="px-4 py-3 text-neutral-500">{po.approvedAt ? formatDate(po.approvedAt) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<SpendCharts vesselData={vesselChartData} monthData={monthData} />
</div>
);
}
async function AccountsDashboard() {
const [queueCount, queueValueResult] = await Promise.all([
db.purchaseOrder.count({ where: { status: "MGR_APPROVED" } }),
db.purchaseOrder.aggregate({
_sum: { totalAmount: true },
where: { status: "MGR_APPROVED" },
}),
]);
const queueValue = Number(queueValueResult._sum.totalAmount ?? 0);
return (
<div>
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Ready for Payment" value={queueCount} icon={FileText} color="blue" href="/payments" />
<StatCard label="Payment Queue Value" value={formatCurrency(queueValue)} icon={DollarSign} color="green" />
</div>
</div>
);
}
async function GenericDashboard() {
const total = await db.purchaseOrder.count();
return (
<div>
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Total Purchase Orders" value={total} icon={FileText} color="blue" href="/history" />
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
export default async function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
<Sidebar userRole={session.user.role} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header user={session.user} />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}