pelagia-portal/App/app/(portal)/approvals/[id]/manager-edit-po-form.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

311 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { managerEditPo } from "./manager-po-edit-actions";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
type SerializedLineItem = {
id: string;
poId: string;
name: string;
description: string | null;
quantity: number;
unit: string;
size: string | null;
unitPrice: number;
totalPrice: number;
gstRate: number;
sortOrder: number;
productId: string | null;
};
type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
totalAmount: number;
lineItems: SerializedLineItem[];
vessel: { id: string; name: string } | null;
account: { id: string; name: string; code: string };
vendor: { id: string; name: string; vendorId: string | null } | null;
};
interface Props {
po: PoFull;
costCentres: CostCentreOption[];
initialCostCentreRef: string;
accounts: AccountGroup[];
vendors: Vendor[];
}
const INPUT =
"w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30 placeholder:text-neutral-400";
const LABEL = "block text-xs font-semibold text-amber-800 mb-1";
export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const extPo = po as typeof po & {
piQuotationNo?: string | null; piQuotationDate?: Date | null;
requisitionNo?: string | null; requisitionDate?: Date | null;
placeOfDelivery?: string | null;
tcDelivery?: string | null; tcDispatch?: string | null;
tcInspection?: string | null; tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null; tcOthers?: string | null;
};
const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({
name: li.name,
description: li.description ?? undefined,
quantity: li.quantity,
unit: li.unit,
size: li.size ?? undefined,
unitPrice: li.unitPrice,
gstRate: li.gstRate,
}))
);
function isoDate(d: Date | null | undefined) {
if (!d) return "";
return new Date(d).toISOString().split("T")[0];
}
async function handleSave() {
setPending(true);
setError("");
const form = document.getElementById("mgr-edit-po-form") as HTMLFormElement;
const data = new FormData(form);
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
data.set(`lineItems[${i}].quantity`, String(item.quantity));
data.set(`lineItems[${i}].unit`, item.unit);
data.set(`lineItems[${i}].size`, item.size ?? "");
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
});
const result = await managerEditPo(po.id, data);
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setSaved(true);
setEditing(false);
setPending(false);
router.refresh();
}
}
if (!editing) {
return (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-6 py-4">
{saved && (
<p className="mb-3 text-xs text-amber-700 bg-amber-100 border border-amber-200 rounded px-3 py-1.5">
PO updated. Original values are preserved in the activity trail.
</p>
)}
<button
onClick={() => setEditing(true)}
className="text-sm text-amber-700 hover:text-amber-900 font-semibold underline underline-offset-2"
>
Edit PO (Manager)
</button>
<p className="mt-1 text-xs text-amber-600">
Edit any field on this PO. Original values will be saved to the audit trail.
</p>
</div>
);
}
return (
<div className="mt-2 rounded-lg border-2 border-amber-400 bg-amber-50">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-amber-200">
<div>
<p className="text-sm font-bold text-amber-800 uppercase tracking-wide">Manager Editing PO</p>
<p className="text-xs text-amber-600 mt-0.5">All changes will be saved to the audit trail with the original values.</p>
</div>
<button
onClick={() => { setEditing(false); setError(""); }}
className="text-xs text-amber-700 hover:text-amber-900 underline"
>
Cancel
</button>
</div>
<form id="mgr-edit-po-form" className="p-6 space-y-6" onSubmit={(e) => e.preventDefault()}>
{/* Order Information */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Order Information</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className={LABEL}>Title <span className="text-danger">*</span></label>
<input name="title" required defaultValue={po.title} className={INPUT} />
</div>
<div>
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}>
<optgroup label="Vessels">
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
<optgroup label="Sites">
{costCentres.filter((c) => c.group === "Sites").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
</select>
</div>
<div>
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
<select name="accountId" required defaultValue={po.accountId} className={INPUT}>
{accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select>
</div>
<div>
<label className={LABEL}>Project Code</label>
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
</div>
<div>
<label className={LABEL}>Delivery Date Required</label>
<input name="dateRequired" type="date" defaultValue={isoDate(po.dateRequired)} className={INPUT} />
</div>
</div>
</section>
{/* Quotation Reference */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Quotation Reference</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={LABEL}>PI / Quotation No.</label>
<input name="piQuotationNo" defaultValue={extPo.piQuotationNo ?? ""} className={INPUT} placeholder="e.g. Verbal" />
</div>
<div>
<label className={LABEL}>PI / Quotation Date</label>
<input name="piQuotationDate" type="date" defaultValue={isoDate(extPo.piQuotationDate)} className={INPUT} />
</div>
</div>
</section>
{/* Requisition */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Requisition</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={LABEL}>Vessel / Office Requisition No.</label>
<input name="requisitionNo" defaultValue={extPo.requisitionNo ?? ""} className={INPUT} placeholder="Optional" />
</div>
<div>
<label className={LABEL}>Requisition Date</label>
<input name="requisitionDate" type="date" defaultValue={isoDate(extPo.requisitionDate)} className={INPUT} />
</div>
</div>
</section>
{/* Delivery */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
<label className={LABEL}>Place of Delivery</label>
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
</section>
{/* Vendor */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
<label className={LABEL}>Vendor</label>
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</section>
{/* Line Items */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Line Items</h3>
<div className="rounded-lg border border-amber-200 bg-white p-4">
<LineItemsEditor items={lineItems} onChange={setLineItems} />
</div>
</section>
{/* Terms & Conditions */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms &amp; Conditions</h3>
<div className="space-y-2.5">
<div className="rounded-lg bg-amber-100 border border-amber-200 px-3 py-2 text-xs text-amber-700 select-none">
<span className="font-semibold">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right">{n}.</span>
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800">{label}</label>
<input
name={name}
defaultValue={(extPo[key] ?? TC_DEFAULTS[key]) as string}
className={INPUT}
/>
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right mt-2">7.</span>
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800 mt-2">Others</label>
<textarea
name="tcOthers"
rows={2}
defaultValue={(extPo.tcOthers ?? TC_DEFAULTS.tcOthers) as string}
className={INPUT}
/>
</div>
</div>
</section>
{error && (
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
<div className="flex items-center gap-3 pt-2 border-t border-amber-200">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="rounded-lg bg-amber-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-amber-700 disabled:opacity-60 transition-colors"
>
{pending ? "Saving…" : "Save Changes"}
</button>
<button
type="button"
onClick={() => { setEditing(false); setError(""); }}
disabled={pending}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
>
Cancel
</button>
</div>
</form>
</div>
);
}