feat(reports): drill from cost centre / accounting code into PO History (#126)
Report detail pages now link to the underlying POs, addressing the PR #126 review comment: drilling into a cost centre or accounting code opens PO History pre-filtered to that dimension and the period in view. - Cost Centre / Accounting Code detail pages gain a "View POs" link. - periodRange() maps the on-screen period onto History's approved-date window (weekly→month, monthly→FY, yearly→full span); spend is dated by approvedAt. - PO History gains an accountId filter (any tree node, expanded to leaves via accountLeafIds()) matching PO-level OR line-item accounts — the same basis the reports use. - History page + CSV/PDF export share one buildPoHistoryWhere() builder so they never diverge. - Tests: unit (periodRange, accountLeafIds) + integration (History account filter across PO-level/line-item, with the approved window). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ed27d668b
commit
fd7b31e64d
11 changed files with 370 additions and 69 deletions
|
|
@ -164,6 +164,8 @@ Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** sub
|
|||
|
||||
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
|
||||
|
||||
**Drill to POs (#126):** each detail page (`/reports/cost-centres/[id]`, `/reports/accounting-codes/[id]`) has a **"View POs"** link to **PO History** pre-filtered to that cost centre / accounting code over the period in view — `periodRange(gran, fy, month, fys)` (`lib/reports.ts`) maps the on-screen period onto History's `approvedFrom`/`approvedTo` (weekly → the focused month, monthly → the FY, yearly → the full FY span; spend is dated by `approvedAt`). PO History (`/history`) gained an **`accountId`** filter that accepts **any** account-tree node and matches a PO whose **PO-level account or any line-item account** is a leaf under it (`accountLeafIds()` expands the node) — the same attribution basis the reports use. The History page **and** its CSV/PDF export route (`/api/reports/export`) build their `where` from one shared `lib/history-filter.ts` `buildPoHistoryWhere()` so they stay in lockstep.
|
||||
|
||||
Sites are **not** cost centres (only vessels are).
|
||||
|
||||
### Crewing (feature-flagged)
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ const STATUSES = [
|
|||
|
||||
interface Props {
|
||||
vessels: { id: string; name: string }[];
|
||||
accounts: { id: string; code: string; name: string }[];
|
||||
perPageOptions: number[];
|
||||
defaultPerPage: number;
|
||||
}
|
||||
|
||||
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
|
||||
export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||
const [accountId, setAccountId] = useState(sp.get("accountId") ?? "");
|
||||
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -64,6 +66,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
||||
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||
if (vesselId) params.set("vesselId", vesselId);
|
||||
if (accountId) params.set("accountId", accountId);
|
||||
for (const s of statuses) params.append("status", s);
|
||||
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
||||
return params;
|
||||
|
|
@ -78,14 +81,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
}
|
||||
|
||||
function clear() {
|
||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setAccountId(""); setStatuses([]);
|
||||
const params = new URLSearchParams();
|
||||
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/history?${qs}` : "/history");
|
||||
}
|
||||
|
||||
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || accountId || statuses.length > 0;
|
||||
|
||||
const statusLabel =
|
||||
statuses.length === 0
|
||||
|
|
@ -125,6 +128,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
|
||||
<select value={accountId} onChange={(e) => setAccountId(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">
|
||||
<option value="">All accounting codes</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.code} · {a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative" ref={statusRef}>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import Link from "next/link";
|
|||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||
import { HistoryFilters } from "./history-filters";
|
||||
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
||||
import { resolvePagination } from "@/lib/pagination";
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
export const metadata: Metadata = { title: "History" };
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ interface Props {
|
|||
approvedFrom?: string;
|
||||
approvedTo?: string;
|
||||
vesselId?: string;
|
||||
accountId?: string;
|
||||
status?: string | string[];
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
|
|
@ -42,33 +43,13 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
|
||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, status, page: pageParam, perPage: perPageParam } =
|
||||
await searchParams;
|
||||
|
||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||
if (dateFrom || dateTo) {
|
||||
const createdAt: { gte?: Date; lt?: Date } = {};
|
||||
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
||||
if (dateTo) {
|
||||
const end = new Date(dateTo);
|
||||
end.setDate(end.getDate() + 1);
|
||||
createdAt.lt = end;
|
||||
}
|
||||
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;
|
||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||
const where = await buildPoHistoryWhere({
|
||||
dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, statuses,
|
||||
});
|
||||
|
||||
const total = await db.purchaseOrder.count({ where });
|
||||
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
||||
|
|
@ -79,7 +60,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
defaultPerPage: DEFAULT_PER_PAGE,
|
||||
});
|
||||
|
||||
const [orders, vessels] = await Promise.all([
|
||||
const [orders, vessels, accounts] = await Promise.all([
|
||||
db.purchaseOrder.findMany({
|
||||
where,
|
||||
include: { submitter: true, vessel: true, account: true },
|
||||
|
|
@ -88,6 +69,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
take,
|
||||
}),
|
||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.account.findMany({ orderBy: { code: "asc" }, select: { id: true, code: true, name: true } }),
|
||||
]);
|
||||
|
||||
// Shared filter params for the pagination footer links (everything except `page`).
|
||||
|
|
@ -97,6 +79,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
||||
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
||||
if (vesselId) pageParams.set("vesselId", vesselId);
|
||||
if (accountId) pageParams.set("accountId", accountId);
|
||||
for (const s of statuses) pageParams.append("status", s);
|
||||
pageParams.set("perPage", String(perPage));
|
||||
|
||||
|
|
@ -115,6 +98,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||
if (accountId) exportParams.set("accountId", accountId);
|
||||
for (const s of statuses) exportParams.append("status", s);
|
||||
|
||||
return (
|
||||
|
|
@ -140,7 +124,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
</div>
|
||||
|
||||
<Suspense>
|
||||
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||||
<HistoryFilters vessels={vessels} accounts={accounts} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||||
</Suspense>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
accountNodeWeekly,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
periodRange,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
|
|
@ -89,6 +90,10 @@ export default async function AccountingCodeDetail({
|
|||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
|
||||
// Drill into the POs behind this spend: PO History filtered to this accounting
|
||||
// code (expanded to its leaves) over the period in view (dated by approvedAt).
|
||||
const { from, to } = periodRange(gran, fy, month, ds.fys);
|
||||
const poListHref = `/history?accountId=${id}&approvedFrom=${from}&approvedTo=${to}`;
|
||||
|
||||
const path = idx.pathTo(id);
|
||||
const trail = [
|
||||
|
|
@ -111,12 +116,17 @@ export default async function AccountingCodeDetail({
|
|||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
← Back to Accounting Codes
|
||||
</Link>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
← Back to Accounting Codes
|
||||
</Link>
|
||||
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
||||
View POs · {periodLabel} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ReportTitle
|
||||
title={`${node.code} · ${node.name}`}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
topAccountsForCostCentre,
|
||||
periodRange,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
|
|
@ -80,6 +81,10 @@ export default async function CostCentreDetail({
|
|||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
|
||||
// Drill into the POs behind this spend: PO History filtered to this cost centre
|
||||
// over the period currently in view (spend is dated by approvedAt).
|
||||
const { from, to } = periodRange(gran, fy, month, ds.fys);
|
||||
const poListHref = `/history?vesselId=${id}&approvedFrom=${from}&approvedTo=${to}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -93,9 +98,14 @@ export default async function CostCentreDetail({
|
|||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to Cost Centres
|
||||
</Link>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to Cost Centres
|
||||
</Link>
|
||||
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
||||
View POs · {periodLabel} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
||||
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
const PO_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
||||
|
|
@ -25,36 +25,16 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const sp = request.nextUrl.searchParams;
|
||||
const format = sp.get("format") ?? "csv";
|
||||
const dateFrom = sp.get("dateFrom");
|
||||
const dateTo = sp.get("dateTo");
|
||||
const approvedFrom = sp.get("approvedFrom");
|
||||
const approvedTo = sp.get("approvedTo");
|
||||
const vesselId = sp.get("vesselId");
|
||||
const statuses = sp.getAll("status").filter(Boolean);
|
||||
|
||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||
if (dateFrom || dateTo) {
|
||||
const createdAt: { gte?: Date; lt?: Date } = {};
|
||||
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
||||
if (dateTo) {
|
||||
const end = new Date(dateTo);
|
||||
end.setDate(end.getDate() + 1);
|
||||
createdAt.lt = end;
|
||||
}
|
||||
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 (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||
const where = await buildPoHistoryWhere({
|
||||
dateFrom: sp.get("dateFrom"),
|
||||
dateTo: sp.get("dateTo"),
|
||||
approvedFrom: sp.get("approvedFrom"),
|
||||
approvedTo: sp.get("approvedTo"),
|
||||
vesselId: sp.get("vesselId"),
|
||||
accountId: sp.get("accountId"),
|
||||
statuses: sp.getAll("status"),
|
||||
});
|
||||
|
||||
const orders = await db.purchaseOrder.findMany({
|
||||
where,
|
||||
|
|
|
|||
68
App/lib/history-filter.ts
Normal file
68
App/lib/history-filter.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Shared `where` builder for the PO History list (`/history` page) and its
|
||||
* CSV/PDF export route, so the two never drift. Filters: created-date range,
|
||||
* approved-date range, cost centre (vessel), status, and — for report
|
||||
* drill-downs (issue #124 review) — an accounting code.
|
||||
*
|
||||
* The `accountId` filter accepts any account-tree node (Heading / Sub-heading /
|
||||
* Leaf); it expands to the leaf codes underneath via `accountLeafIds` and
|
||||
* matches a PO whose **PO-level account** or **any line item account** is in
|
||||
* that leaf set — the same attribution basis the spend reports use.
|
||||
*/
|
||||
import { db } from "@/lib/db";
|
||||
import { accountLeafIds } from "@/lib/reports";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
type PoWhere = NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"];
|
||||
|
||||
export interface HistoryFilterParams {
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
approvedFrom?: string | null;
|
||||
approvedTo?: string | null;
|
||||
vesselId?: string | null;
|
||||
accountId?: string | null;
|
||||
statuses?: string[];
|
||||
}
|
||||
|
||||
export async function buildPoHistoryWhere(p: HistoryFilterParams): Promise<PoWhere> {
|
||||
const where: NonNullable<PoWhere> = {};
|
||||
|
||||
if (p.dateFrom || p.dateTo) {
|
||||
const createdAt: { gte?: Date; lt?: Date } = {};
|
||||
if (p.dateFrom) createdAt.gte = new Date(p.dateFrom);
|
||||
if (p.dateTo) {
|
||||
const end = new Date(p.dateTo);
|
||||
end.setDate(end.getDate() + 1);
|
||||
createdAt.lt = end;
|
||||
}
|
||||
where.createdAt = createdAt;
|
||||
}
|
||||
|
||||
if (p.approvedFrom || p.approvedTo) {
|
||||
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||
if (p.approvedFrom) approvedAt.gte = new Date(p.approvedFrom);
|
||||
if (p.approvedTo) {
|
||||
const end = new Date(p.approvedTo);
|
||||
end.setDate(end.getDate() + 1);
|
||||
approvedAt.lt = end;
|
||||
}
|
||||
where.approvedAt = approvedAt;
|
||||
}
|
||||
|
||||
if (p.vesselId) where.vesselId = p.vesselId;
|
||||
|
||||
const statuses = (p.statuses ?? []).filter(Boolean);
|
||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||
|
||||
if (p.accountId) {
|
||||
const accounts = await db.account.findMany({ select: { id: true, parentId: true } });
|
||||
const leaves = accountLeafIds(accounts, p.accountId);
|
||||
where.OR = [
|
||||
{ accountId: { in: leaves } },
|
||||
{ lineItems: { some: { accountId: { in: leaves } } } },
|
||||
];
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
|
@ -350,3 +350,65 @@ export function parseSel(v: string | undefined): string[] {
|
|||
export function toggleSel(sel: string[], id: string): string[] {
|
||||
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
|
||||
}
|
||||
|
||||
// ── Report → PO drill-down ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Leaf account ids under `accountId` (the node itself when it is already a
|
||||
* leaf), from the raw `{ id, parentId }` account rows. A report drill-down can
|
||||
* target any tier, but a PO / line item only ever carries a leaf code — so this
|
||||
* translates a drilled node into the concrete leaf set PO History filters by.
|
||||
* Returns `[]` for an unknown id.
|
||||
*/
|
||||
export function accountLeafIds(
|
||||
accounts: { id: string; parentId: string | null }[],
|
||||
accountId: string,
|
||||
): string[] {
|
||||
const ids = new Set(accounts.map((a) => a.id));
|
||||
if (!ids.has(accountId)) return [];
|
||||
const kids = new Map<string, string[]>();
|
||||
for (const a of accounts) {
|
||||
if (a.parentId === null) continue;
|
||||
if (!kids.has(a.parentId)) kids.set(a.parentId, []);
|
||||
kids.get(a.parentId)!.push(a.id);
|
||||
}
|
||||
const out: string[] = [];
|
||||
const walk = (id: string) => {
|
||||
const cs = kids.get(id) ?? [];
|
||||
if (cs.length === 0) out.push(id);
|
||||
else cs.forEach(walk);
|
||||
};
|
||||
walk(accountId);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The approved-date window (`from`..`to`, inclusive `YYYY-MM-DD`) a report
|
||||
* detail view currently shows, so drilling into the underlying POs carries
|
||||
* "that period" onto PO History's `approvedFrom`/`approvedTo` (spend is dated by
|
||||
* `approvedAt`). Mirrors the on-screen period label:
|
||||
* - weekly → the focused FY month
|
||||
* - monthly → the whole selected FY (Apr–Mar)
|
||||
* - yearly → the full span of FYs in the dataset
|
||||
*/
|
||||
export function periodRange(
|
||||
gran: Granularity,
|
||||
fy: number,
|
||||
month: number,
|
||||
fys: number[],
|
||||
): { from: string; to: string } {
|
||||
const iso = (y: number, m: number, d: number) =>
|
||||
`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
if (gran === "yearly") {
|
||||
const first = fys[0] ?? fy;
|
||||
const last = fys[fys.length - 1] ?? fy;
|
||||
return { from: iso(first, 4, 1), to: iso(last + 1, 3, 31) };
|
||||
}
|
||||
if (gran === "weekly") {
|
||||
const cal = (month + 3) % 12; // FY-month index (Apr=0) → calendar month 0–11
|
||||
const year = fy + (month >= 9 ? 1 : 0); // Jan–Mar roll into the next calendar year
|
||||
const lastDay = new Date(year, cal + 1, 0).getDate();
|
||||
return { from: iso(year, cal + 1, 1), to: iso(year, cal + 1, lastDay) };
|
||||
}
|
||||
return { from: iso(fy, 4, 1), to: iso(fy + 1, 3, 31) };
|
||||
}
|
||||
|
|
|
|||
128
App/tests/integration/history-account-filter.test.ts
Normal file
128
App/tests/integration/history-account-filter.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Integration test for the PO History accounting-code filter (PR #126 review).
|
||||
*
|
||||
* Report drill-downs link from a cost-centre / accounting-code detail page into
|
||||
* `/history` with the code (and period) applied as filters. `buildPoHistoryWhere`
|
||||
* powers both the History page and its export route. The accounting-code filter
|
||||
* accepts any tree node and must match a PO whose **PO-level account** OR **any
|
||||
* line item account** falls under that node's leaves — the same attribution
|
||||
* basis the spend reports use.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
||||
import { deletePosByTitle } from "./helpers";
|
||||
|
||||
const PREFIX = "INTTEST_HIST_ACCT_";
|
||||
const CODE = `INTTEST_${Date.now()}_`;
|
||||
|
||||
let submitterId: string;
|
||||
let vesselId: string;
|
||||
let topId: string; // heading
|
||||
let leafAId: string;
|
||||
let leafBId: string;
|
||||
let leafXId: string; // unrelated leaf
|
||||
|
||||
const approvedAt = new Date("2025-06-15T12:00:00Z"); // FY 2025–26
|
||||
|
||||
async function makePo(opts: {
|
||||
title: string;
|
||||
accountId: string;
|
||||
lineAccountId?: string | null;
|
||||
}) {
|
||||
return db.purchaseOrder.create({
|
||||
data: {
|
||||
poNumber: `${PREFIX}${opts.title}`,
|
||||
title: `${PREFIX}${opts.title}`,
|
||||
status: "CLOSED",
|
||||
totalAmount: 1000,
|
||||
approvedAt,
|
||||
submitterId,
|
||||
vesselId,
|
||||
accountId: opts.accountId,
|
||||
lineItems: {
|
||||
create: [
|
||||
{
|
||||
name: "Item",
|
||||
quantity: 1,
|
||||
unit: "pc",
|
||||
unitPrice: 1000,
|
||||
totalPrice: 1000,
|
||||
accountId: opts.lineAccountId ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const [user, vessel] = await Promise.all([
|
||||
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
|
||||
db.vessel.findFirstOrThrow(),
|
||||
]);
|
||||
submitterId = user.id;
|
||||
vesselId = vessel.id;
|
||||
|
||||
// Brand-new account subtree (T → S → A, B) + an unrelated leaf X, so no
|
||||
// pre-existing prod PO references them and counts isolate to our fixtures.
|
||||
const top = await db.account.create({ data: { code: `${CODE}T`, name: "IntTest Top" } });
|
||||
const sub = await db.account.create({ data: { code: `${CODE}S`, name: "IntTest Sub", parentId: top.id } });
|
||||
const a = await db.account.create({ data: { code: `${CODE}A`, name: "IntTest Leaf A", parentId: sub.id } });
|
||||
const b = await db.account.create({ data: { code: `${CODE}B`, name: "IntTest Leaf B", parentId: sub.id } });
|
||||
const x = await db.account.create({ data: { code: `${CODE}X`, name: "IntTest Leaf X" } });
|
||||
topId = top.id;
|
||||
leafAId = a.id;
|
||||
leafBId = b.id;
|
||||
leafXId = x.id;
|
||||
|
||||
await makePo({ title: "po1_levelA", accountId: leafAId }); // PO-level A
|
||||
await makePo({ title: "po2_levelX_lineB", accountId: leafXId, lineAccountId: leafBId }); // line item B
|
||||
await makePo({ title: "po3_levelX", accountId: leafXId }); // X only
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deletePosByTitle(PREFIX);
|
||||
await db.account.deleteMany({ where: { code: { startsWith: CODE } } });
|
||||
});
|
||||
|
||||
async function countMine(accountId: string) {
|
||||
const where = await buildPoHistoryWhere({ accountId });
|
||||
return db.purchaseOrder.count({ where: { ...where, title: { startsWith: PREFIX } } });
|
||||
}
|
||||
|
||||
describe("PO History accounting-code filter", () => {
|
||||
it("a heading expands to its leaves and matches PO-level or line-item accounts", async () => {
|
||||
// T → {A, B}: po1 (PO-level A) and po2 (line item B); not po3 (X only).
|
||||
expect(await countMine(topId)).toBe(2);
|
||||
});
|
||||
|
||||
it("a leaf matches only POs carrying that exact code (PO-level)", async () => {
|
||||
expect(await countMine(leafAId)).toBe(1); // po1
|
||||
});
|
||||
|
||||
it("matches a PO via a line-item account even when the PO-level account differs", async () => {
|
||||
expect(await countMine(leafBId)).toBe(1); // po2 (PO-level X, line item B)
|
||||
});
|
||||
|
||||
it("the unrelated leaf matches its own POs", async () => {
|
||||
expect(await countMine(leafXId)).toBe(2); // po2 + po3 (both PO-level X)
|
||||
});
|
||||
|
||||
it("combines with the approved-date window", async () => {
|
||||
const inWindow = await buildPoHistoryWhere({
|
||||
accountId: topId,
|
||||
approvedFrom: "2025-04-01",
|
||||
approvedTo: "2026-03-31",
|
||||
});
|
||||
expect(await db.purchaseOrder.count({ where: { ...inWindow, title: { startsWith: PREFIX } } })).toBe(2);
|
||||
|
||||
const outOfWindow = await buildPoHistoryWhere({
|
||||
accountId: topId,
|
||||
approvedFrom: "2024-04-01",
|
||||
approvedTo: "2025-03-31",
|
||||
});
|
||||
expect(await db.purchaseOrder.count({ where: { ...outOfWindow, title: { startsWith: PREFIX } } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,8 @@ import {
|
|||
parseSel,
|
||||
toggleSel,
|
||||
allocatePoSpend,
|
||||
accountLeafIds,
|
||||
periodRange,
|
||||
type ReportDataset,
|
||||
type AccountNode,
|
||||
} from "@/lib/reports";
|
||||
|
|
@ -49,6 +51,49 @@ const DS: ReportDataset = {
|
|||
],
|
||||
};
|
||||
|
||||
describe("accountLeafIds (report → PO drill-down)", () => {
|
||||
const RAW = ACCOUNTS.map((a) => ({ id: a.id, parentId: a.parentId }));
|
||||
|
||||
it("expands a heading to every leaf underneath it", () => {
|
||||
expect(accountLeafIds(RAW, "H").sort()).toEqual(["L1", "L2"]);
|
||||
expect(accountLeafIds(RAW, "S").sort()).toEqual(["L1", "L2"]);
|
||||
});
|
||||
it("returns a leaf node as itself", () => {
|
||||
expect(accountLeafIds(RAW, "L1")).toEqual(["L1"]);
|
||||
});
|
||||
it("returns [] for an unknown id", () => {
|
||||
expect(accountLeafIds(RAW, "nope")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("periodRange (report → PO History approved window)", () => {
|
||||
it("monthly → the whole selected FY (Apr–Mar)", () => {
|
||||
expect(periodRange("monthly", 2025, 0, [2024, 2025])).toEqual({
|
||||
from: "2025-04-01",
|
||||
to: "2026-03-31",
|
||||
});
|
||||
});
|
||||
it("yearly → the full span of FYs in the dataset", () => {
|
||||
expect(periodRange("yearly", 2025, 0, [2024, 2025])).toEqual({
|
||||
from: "2024-04-01",
|
||||
to: "2026-03-31",
|
||||
});
|
||||
});
|
||||
it("weekly → the focused FY month (Apr=0)", () => {
|
||||
expect(periodRange("weekly", 2025, 0, [2025])).toEqual({
|
||||
from: "2025-04-01",
|
||||
to: "2025-04-30",
|
||||
});
|
||||
});
|
||||
it("weekly → a Jan–Mar month rolls into the next calendar year", () => {
|
||||
// FY-month index 9 = Jan, which belongs to calendar year fy+1.
|
||||
expect(periodRange("weekly", 2025, 9, [2025])).toEqual({
|
||||
from: "2026-01-01",
|
||||
to: "2026-01-31",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("financial-year helpers", () => {
|
||||
it("maps Apr–Mar to the Indian FY start year", () => {
|
||||
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- **Report → PO drill-down** (#126) — the Cost Centre and Accounting Code report detail pages gain a **"View POs"** link that opens **PO History** pre-filtered to that cost centre / accounting code and the period currently in view (mapped to the approved-date window, since spend is dated by `approvedAt`). PO History gains an **Accounting Code** filter that accepts any tree node and matches a PO whose PO-level account **or** any line-item account falls under that node's leaves. The History page and its CSV/PDF export share one `buildPoHistoryWhere` builder so they never diverge.
|
||||
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
|
||||
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
|
||||
- **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue