pelagia-portal/App/app/(portal)/approvals/[id]/page.tsx
Hardik 0d17672ea9 feat(accounts): hierarchical accounting codes with 6-digit format and category tree
- Account model gains parentId (self-referential, 3 levels: TopCategory → SubCategory → Item)
- DB migration: adds parentId FK column to Account table
- Code format changed from PREFIX-NNN to 6-digit numeric (e.g. 100101)
- Seeded all 300+ accounting codes from the official chart (Rev. 01/251227) across
  7 top categories: Capital Expenses, Business Development, Office Admin, Project
  Expenses, Manning, Technical, Bunker/Lubes
- Admin Accounting Code page: collapsible tree view (top category > sub-category > items),
  inline search, Add/Edit dialogs with parent selector and 6-digit code field
- All PO forms (new, edit, import, manager-edit): accounting code dropdown now shows
  only leaf items grouped in <optgroup> by sub-category, labelled "TopCat › SubCat"
- Seed data updated: old flat account codes replaced by mapped leaf codes from new hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 03:27:31 +05:30

142 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { notFound, redirect } from "next/navigation";
import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata: Metadata = { title: "Review PO" };
export default async function ApprovalDetailPage({ params }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
const { id } = await params;
// Check if manager has uploaded a signature — required to approve
const currentUser = await db.user.findUnique({
where: { id: session.user.id },
select: { signatureKey: true },
});
const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, sites, accounts, vendors] = await Promise.all([
db.purchaseOrder.findUnique({
where: { id },
include: {
submitter: true,
vessel: true,
site: { select: { id: true, name: true } },
account: true,
vendor: true,
lineItems: { orderBy: { sortOrder: "asc" } },
documents: { orderBy: { uploadedAt: "desc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
receipt: true,
},
}),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: 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 } } } } },
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
]);
if (!po) notFound();
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
];
const initialCostCentreRef = po ? (po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "") : "";
const serializedPo = {
...po,
totalAmount: po.totalAmount.toNumber(),
lineItems: po.lineItems.map((li) => ({
...li,
quantity: li.quantity.toNumber(),
unitPrice: li.unitPrice.toNumber(),
totalPrice: li.totalPrice.toNumber(),
gstRate: li.gstRate.toNumber(),
})),
};
return (
<div className="max-w-6xl">
<div className="mb-4 md:mb-6 flex items-center justify-between">
<div>
<h1 className="text-lg md:text-2xl font-semibold text-neutral-900">Review Purchase Order</h1>
<p className="mt-0.5 text-sm text-neutral-500">{po.poNumber} {po.title}</p>
</div>
</div>
{!po.vendorId && (
<div className="mb-4 rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
<p className="text-sm font-medium text-danger-700">Vendor required before approval</p>
<p className="text-sm text-danger-600 mt-0.5">
This PO has no vendor assigned. Use &ldquo;Request Vendor ID&rdquo; to route it for vendor selection, or assign a vendor via the Edit PO form on desktop.
</p>
</div>
)}
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
{/* Direct field editing is desktop-only */}
<div className="hidden md:block">
<ManagerEditPoForm
po={serializedPo}
costCentres={costCentres}
initialCostCentreRef={initialCostCentreRef}
accounts={accountGroups}
vendors={vendors}
/>
</div>
<div className="mt-4 md:mt-6">
{hasSignature ? (
<ApprovalActions poId={po.id} poStatus={po.status} />
) : (
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
<span className="text-warning-500 text-xl leading-none mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-warning-800">Signature required to approve POs</p>
<p className="text-sm text-warning-700 mt-0.5">
You must upload your approval signature before you can approve, reject, or request edits on purchase orders.
</p>
<a
href="/profile"
className="mt-2 inline-block text-sm font-medium text-primary-600 hover:text-primary-700 underline"
>
Go to Profile to upload your signature
</a>
</div>
</div>
)}
</div>
</div>
);
}