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:
parent
77aafcce99
commit
94774ca96b
2 changed files with 284 additions and 0 deletions
261
App/pelagia-portal/app/(portal)/dashboard/page.tsx
Normal file
261
App/pelagia-portal/app/(portal)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
App/pelagia-portal/app/(portal)/layout.tsx
Normal file
23
App/pelagia-portal/app/(portal)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue