feat(history): add Accounting Code search filter to PO History
PO History had no way to narrow by accounting code. Add an "Accounting Code" filter (the shared type-to-search combobox) alongside Cost Centre, backed by the PO-level account already included in the query. - history/page.tsx: read `accountId` searchParam, fetch selectable leaf accounting codes (active, no children) via buildAccountGroups, apply `where.accountId`, thread the param into pagination + export links, and surface an Accounting Code column for context. - history-filters.tsx: new SearchableSelect control wired into buildParams/apply/clear/hasFilters like the Cost Centre select. - api/reports/export: apply the same `accountId` filter so CSV/PDF export respects the on-screen filter. - tests/staging: verify picking a code drives an `accountId` query param. Fixes #121 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fcb207add
commit
3e8f5fb0c7
4 changed files with 65 additions and 6 deletions
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ value: "DRAFT", label: "Draft" },
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
|
@ -19,11 +21,12 @@ const STATUSES = [
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
vessels: { id: string; name: string }[];
|
||||||
|
accounts: AccountGroup[];
|
||||||
perPageOptions: number[];
|
perPageOptions: number[];
|
||||||
defaultPerPage: number;
|
defaultPerPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
|
export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
|
|
@ -36,6 +39,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||||
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
|
const [accountId, setAccountId] = useState(sp.get("accountId") ?? "");
|
||||||
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const statusRef = useRef<HTMLDivElement>(null);
|
const statusRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -64,6 +68,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) params.set("approvedTo", approvedTo);
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
|
if (accountId) params.set("accountId", accountId);
|
||||||
for (const s of statuses) params.append("status", s);
|
for (const s of statuses) params.append("status", s);
|
||||||
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
||||||
return params;
|
return params;
|
||||||
|
|
@ -78,14 +83,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setAccountId(""); setStatuses([]);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
router.push(qs ? `/history?${qs}` : "/history");
|
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 =
|
const statusLabel =
|
||||||
statuses.length === 0
|
statuses.length === 0
|
||||||
|
|
@ -125,6 +130,16 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
|
||||||
|
<SearchableSelect
|
||||||
|
name="accountId"
|
||||||
|
value={accountId}
|
||||||
|
onChange={setAccountId}
|
||||||
|
groups={accounts}
|
||||||
|
placeholder="All accounting codes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="relative" ref={statusRef}>
|
<div className="relative" ref={statusRef}>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||||
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { HistoryFilters } from "./history-filters";
|
import { HistoryFilters } from "./history-filters";
|
||||||
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
import { resolvePagination } from "@/lib/pagination";
|
import { resolvePagination } from "@/lib/pagination";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
@ -23,6 +24,7 @@ interface Props {
|
||||||
approvedFrom?: string;
|
approvedFrom?: string;
|
||||||
approvedTo?: string;
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
|
accountId?: string;
|
||||||
status?: string | string[];
|
status?: string | string[];
|
||||||
page?: string;
|
page?: string;
|
||||||
perPage?: string;
|
perPage?: string;
|
||||||
|
|
@ -42,7 +44,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
redirect("/dashboard");
|
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;
|
await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
|
|
@ -67,6 +69,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
where.approvedAt = approvedAt;
|
where.approvedAt = approvedAt;
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
|
if (accountId) where.accountId = accountId;
|
||||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
|
|
@ -79,7 +82,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
defaultPerPage: DEFAULT_PER_PAGE,
|
defaultPerPage: DEFAULT_PER_PAGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels, leafAccounts] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true },
|
include: { submitter: true, vessel: true, account: true },
|
||||||
|
|
@ -88,8 +91,15 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
take,
|
take,
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.account.findMany({
|
||||||
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
orderBy: { code: "asc" },
|
||||||
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
|
||||||
// Shared filter params for the pagination footer links (everything except `page`).
|
// Shared filter params for the pagination footer links (everything except `page`).
|
||||||
const pageParams = new URLSearchParams();
|
const pageParams = new URLSearchParams();
|
||||||
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
||||||
|
|
@ -97,6 +107,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) pageParams.set("vesselId", vesselId);
|
if (vesselId) pageParams.set("vesselId", vesselId);
|
||||||
|
if (accountId) pageParams.set("accountId", accountId);
|
||||||
for (const s of statuses) pageParams.append("status", s);
|
for (const s of statuses) pageParams.append("status", s);
|
||||||
pageParams.set("perPage", String(perPage));
|
pageParams.set("perPage", String(perPage));
|
||||||
|
|
||||||
|
|
@ -115,6 +126,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
|
if (accountId) exportParams.set("accountId", accountId);
|
||||||
for (const s of statuses) exportParams.append("status", s);
|
for (const s of statuses) exportParams.append("status", s);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -140,7 +152,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<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>
|
</Suspense>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
@ -150,6 +162,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
<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">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">Title</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Accounting Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</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-right font-medium text-neutral-600">Amount</th>
|
||||||
|
|
@ -169,6 +182,9 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
<td className="px-4 py-3 font-medium 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 text-neutral-600">{po.vessel.name}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">
|
||||||
|
<span className="font-mono text-xs text-neutral-400">{po.account.code}</span> {po.account.name}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<PoStatusBadge status={po.status} />
|
<PoStatusBadge status={po.status} />
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export async function GET(request: NextRequest) {
|
||||||
const approvedFrom = sp.get("approvedFrom");
|
const approvedFrom = sp.get("approvedFrom");
|
||||||
const approvedTo = sp.get("approvedTo");
|
const approvedTo = sp.get("approvedTo");
|
||||||
const vesselId = sp.get("vesselId");
|
const vesselId = sp.get("vesselId");
|
||||||
|
const accountId = sp.get("accountId");
|
||||||
const statuses = sp.getAll("status").filter(Boolean);
|
const statuses = sp.getAll("status").filter(Boolean);
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
|
|
@ -54,6 +55,7 @@ export async function GET(request: NextRequest) {
|
||||||
where.approvedAt = approvedAt;
|
where.approvedAt = approvedAt;
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
|
if (accountId) where.accountId = accountId;
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
const orders = await db.purchaseOrder.findMany({
|
const orders = await db.purchaseOrder.findMany({
|
||||||
|
|
|
||||||
26
App/tests/staging/issue-121-history-accounting-code.spec.ts
Normal file
26
App/tests/staging/issue-121-history-accounting-code.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { USERS, login } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #121 — PO History gains an Accounting Code filter. The control is the
|
||||||
|
* shared type-to-search combobox; picking a code and applying narrows the list
|
||||||
|
* via an `accountId` query param (mirrored by the CSV/PDF export links).
|
||||||
|
*/
|
||||||
|
test("#121 history can be filtered by accounting code", async ({ page }) => {
|
||||||
|
await login(page, USERS.MANAGER);
|
||||||
|
await page.goto("/history");
|
||||||
|
|
||||||
|
// The Accounting Code control is present (its trigger shows the empty label).
|
||||||
|
const trigger = page.getByRole("button", { name: "All accounting codes" });
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
|
||||||
|
// Open it and pick the first accounting code from the searchable list.
|
||||||
|
await trigger.click();
|
||||||
|
await expect(page.getByPlaceholder("Type code or name…")).toBeVisible();
|
||||||
|
await page.locator(".max-h-72 button").first().click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Apply" }).click();
|
||||||
|
|
||||||
|
// Applying the filter drives an accountId query param.
|
||||||
|
await expect(page).toHaveURL(/accountId=/);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue