fix(dashboard): count all POs approved this month, not just current MGR_APPROVED
The manager dashboard "Approved This Month" card only counted POs whose current status is MGR_APPROVED, so approvals that had already moved on to payment, delivery, or closure dropped out of the count. Managers could not see what happened to the POs they approved this month. - Count every PO whose `approvedAt` falls in the current month across all post-approval statuses (MGR_APPROVED → ... → CLOSED). `approvedAt` is set once at approval and persists, so it is the correct anchor. - Introduce a shared `POST_APPROVAL_STATUSES` constant (includes the previously-omitted PARTIALLY_CLOSED). This also fixes Total Approved Spend and the vessel/monthly breakdowns, which were silently dropping partially-received POs. - Make the card a link into /history with an approval-date filter applied (?approvedFrom=<startOfMonth>) so a click shows the full set with each PO's current status, as requested. - Add `approvedFrom`/`approvedTo` filtering to the history page, its filter UI, and the reports export route so the deep-link and exports stay in sync. Scope note: the count remains org-wide, consistent with every other card on the manager dashboard. Adds an integration test covering the moved-on case and the date window. Fixes #32
This commit is contained in:
parent
b472c149b4
commit
fdc3ebdac9
6 changed files with 171 additions and 7 deletions
|
|
@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { StatCard } from "@/components/dashboard/stat-card";
|
import { StatCard } from "@/components/dashboard/stat-card";
|
||||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
@ -110,11 +110,14 @@ async function ManagerDashboard() {
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||||
|
|
||||||
const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "CLOSED"] as const;
|
const approvedStatuses = POST_APPROVAL_STATUSES;
|
||||||
|
|
||||||
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
|
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
|
||||||
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
|
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
|
||||||
db.purchaseOrder.count({ where: { status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } } }),
|
// POs approved this month — including those that have since moved past
|
||||||
|
// MGR_APPROVED into payment/delivery/closure. `approvedAt` is set once at
|
||||||
|
// approval and persists, so filter on it across all post-approval statuses.
|
||||||
|
db.purchaseOrder.count({ where: { status: { in: [...approvedStatuses] }, approvedAt: { gte: startOfMonth } } }),
|
||||||
db.purchaseOrder.aggregate({
|
db.purchaseOrder.aggregate({
|
||||||
_sum: { totalAmount: true },
|
_sum: { totalAmount: true },
|
||||||
where: { status: { in: [...approvedStatuses] } },
|
where: { status: { in: [...approvedStatuses] } },
|
||||||
|
|
@ -144,6 +147,10 @@ async function ManagerDashboard() {
|
||||||
|
|
||||||
const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0);
|
const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0);
|
||||||
|
|
||||||
|
// Local YYYY-MM-DD for the first of this month, used to deep-link the
|
||||||
|
// "Approved This Month" card into the history page filtered by approval date.
|
||||||
|
const startOfMonthParam = `${startOfMonth.getFullYear()}-${String(startOfMonth.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
// Build monthly series for last 12 months
|
// Build monthly series for last 12 months
|
||||||
const monthlyMap: Record<string, number> = {};
|
const monthlyMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
|
@ -174,7 +181,7 @@ async function ManagerDashboard() {
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
|
<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">
|
<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="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
||||||
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" />
|
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
|
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||||
|
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
const [status, setStatus] = useState(sp.get("status") ?? "");
|
const [status, setStatus] = useState(sp.get("status") ?? "");
|
||||||
|
|
||||||
|
|
@ -34,17 +36,19 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
router.push(`/history?${params.toString()}`);
|
router.push(`/history?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatus("");
|
||||||
router.push("/history");
|
router.push("/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || vesselId || status;
|
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
|
@ -59,6 +63,16 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved From</label>
|
||||||
|
<input type="date" value={approvedFrom} onChange={(e) => setApprovedFrom(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved To</label>
|
||||||
|
<input type="date" value={approvedTo} onChange={(e) => setApprovedTo(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
||||||
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
approvedFrom?: string;
|
||||||
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -27,7 +29,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
||||||
|
|
||||||
const { dateFrom, dateTo, vesselId, status } = await searchParams;
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -40,6 +42,16 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
if (approvedFrom || approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||||||
|
if (approvedTo) {
|
||||||
|
const end = new Date(approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
if (status) where.status = status as POStatus;
|
if (status) where.status = status as POStatus;
|
||||||
|
|
||||||
|
|
@ -56,6 +68,8 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
const exportParams = new URLSearchParams({ format: "csv" });
|
const exportParams = new URLSearchParams({ format: "csv" });
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
if (status) exportParams.set("status", status);
|
if (status) exportParams.set("status", status);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export async function GET(request: NextRequest) {
|
||||||
const format = sp.get("format") ?? "csv";
|
const format = sp.get("format") ?? "csv";
|
||||||
const dateFrom = sp.get("dateFrom");
|
const dateFrom = sp.get("dateFrom");
|
||||||
const dateTo = sp.get("dateTo");
|
const dateTo = sp.get("dateTo");
|
||||||
|
const approvedFrom = sp.get("approvedFrom");
|
||||||
|
const approvedTo = sp.get("approvedTo");
|
||||||
const vesselId = sp.get("vesselId");
|
const vesselId = sp.get("vesselId");
|
||||||
const status = sp.get("status");
|
const status = sp.get("status");
|
||||||
|
|
||||||
|
|
@ -38,6 +40,16 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
if (approvedFrom || approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||||||
|
if (approvedTo) {
|
||||||
|
const end = new Date(approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
if (status) where.status = status as POStatus;
|
if (status) where.status = status as POStatus;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,18 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
||||||
CLOSED: "Closed",
|
CLOSED: "Closed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
||||||
|
// `approvedAt` timestamp as it moves through these states, so "approved this month"
|
||||||
|
// aggregations must match against all of them — not just MGR_APPROVED.
|
||||||
|
export const POST_APPROVAL_STATUSES = [
|
||||||
|
"MGR_APPROVED",
|
||||||
|
"SENT_FOR_PAYMENT",
|
||||||
|
"PARTIALLY_PAID",
|
||||||
|
"PAID_DELIVERED",
|
||||||
|
"PARTIALLY_CLOSED",
|
||||||
|
"CLOSED",
|
||||||
|
] as const satisfies readonly POStatus[];
|
||||||
|
|
||||||
export type BadgeVariant =
|
export type BadgeVariant =
|
||||||
| "default"
|
| "default"
|
||||||
| "secondary"
|
| "secondary"
|
||||||
|
|
|
||||||
105
App/tests/integration/approved-this-month.test.ts
Normal file
105
App/tests/integration/approved-this-month.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Integration test for the manager dashboard "Approved This Month" card.
|
||||||
|
*
|
||||||
|
* Regression: the card previously counted only POs *currently* in MGR_APPROVED,
|
||||||
|
* so POs approved this month that had moved on to payment/delivery/closure were
|
||||||
|
* dropped from the count. The card must count every PO approved this month
|
||||||
|
* regardless of its current (post-approval) status, and the same approval-date
|
||||||
|
* window must be reproducible on the /history page (where the card links to).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||||
|
import { deletePosByTitle } from "./helpers";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const PREFIX = "INTTEST_APPROVED_MONTH_";
|
||||||
|
|
||||||
|
let submitterId: string;
|
||||||
|
let vesselId: string;
|
||||||
|
let accountId: string;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const midThisMonth = new Date(now.getFullYear(), now.getMonth(), 15, 12, 0, 0);
|
||||||
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Resolve any existing cost-centre / account / user from the test DB rather
|
||||||
|
// than relying on dev-seed fixtures (the test DB is a production mirror).
|
||||||
|
const [user, vessel, account] = await Promise.all([
|
||||||
|
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
|
||||||
|
db.vessel.findFirstOrThrow(),
|
||||||
|
db.account.findFirstOrThrow(),
|
||||||
|
]);
|
||||||
|
submitterId = user.id;
|
||||||
|
vesselId = vessel.id;
|
||||||
|
accountId = account.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deletePosByTitle(PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
async function makePo(opts: { title: string; status: POStatus; approvedAt: Date | null }) {
|
||||||
|
seq += 1;
|
||||||
|
return db.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
poNumber: `${PREFIX}${Date.now()}_${seq}`,
|
||||||
|
title: opts.title,
|
||||||
|
status: opts.status,
|
||||||
|
totalAmount: 1000,
|
||||||
|
approvedAt: opts.approvedAt,
|
||||||
|
submitterId,
|
||||||
|
vesselId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the dashboard "Approved This Month" query. */
|
||||||
|
function approvedThisMonthWhere() {
|
||||||
|
return {
|
||||||
|
title: { startsWith: PREFIX },
|
||||||
|
status: { in: [...POST_APPROVAL_STATUSES] },
|
||||||
|
approvedAt: { gte: startOfMonth },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Approved This Month count", () => {
|
||||||
|
it("counts POs approved this month across every post-approval status", async () => {
|
||||||
|
await makePo({ title: `${PREFIX}approved`, status: "MGR_APPROVED", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}sent`, status: "SENT_FOR_PAYMENT", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}partpaid`, status: "PARTIALLY_PAID", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}paid`, status: "PAID_DELIVERED", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}partclosed`, status: "PARTIALLY_CLOSED", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}closed`, status: "CLOSED", approvedAt: midThisMonth });
|
||||||
|
|
||||||
|
const count = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
|
||||||
|
expect(count).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes POs approved in a previous month and POs never approved", async () => {
|
||||||
|
await makePo({ title: `${PREFIX}closed_lastmonth`, status: "CLOSED", approvedAt: lastMonth });
|
||||||
|
await makePo({ title: `${PREFIX}awaiting`, status: "MGR_REVIEW", approvedAt: null });
|
||||||
|
await makePo({ title: `${PREFIX}closed_thismonth`, status: "CLOSED", approvedAt: midThisMonth });
|
||||||
|
|
||||||
|
const count = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("would have missed moved-on POs under the old MGR_APPROVED-only filter", async () => {
|
||||||
|
// A PO approved this month that has since closed — the case from issue #32.
|
||||||
|
await makePo({ title: `${PREFIX}moved_on`, status: "CLOSED", approvedAt: midThisMonth });
|
||||||
|
|
||||||
|
const oldCount = await db.purchaseOrder.count({
|
||||||
|
where: { title: { startsWith: PREFIX }, status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } },
|
||||||
|
});
|
||||||
|
const newCount = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
|
||||||
|
|
||||||
|
expect(oldCount).toBe(0);
|
||||||
|
expect(newCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue