pelagia-portal/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx
Hardik 02c0806d35
All checks were successful
PR checks / checks (pull_request) Successful in 51s
PR checks / integration (pull_request) Successful in 32s
refactor(po): admin-managed Project Codes instead of a static list
Replaces the hardcoded PROJECT_CODES array with an admin-managed
`ProjectCode` model, mirroring the Delivery Locations pattern (PR #100):

- ProjectCode model (unique `code` + isActive) + migration seeding the
  five previously-hardcoded codes; PO.projectCode stays a free-text
  snapshot (no FK) so history/exports/imports are unchanged.
- manage_project_codes permission (Manager + SuperUser + Admin).
- /admin/project-codes CRUD screen (table + Add/Edit + activate/delete)
  and an Administration sidebar link.
- ProjectCodeField now takes `options` from the active codes; the three
  PO forms + pages fetch them from the DB. Static list removed.
- Unit test reworked to the options API; CRUD integration test added;
  documented in App/CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 02:52:03 +05:30

292 lines
12 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 type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { ProjectCodeField } from "@/components/po/project-code-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
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;
vessels: VesselOption[];
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
projectCodeOptions: string[];
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
}
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";
/** Controlled account picker so SearchableSelect can be used inside the uncontrolled manager form */
function ManagerAccountSelect({ accountId, accounts }: { accountId: string; accounts: AccountGroup[] }) {
const [value, setValue] = useState(accountId);
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
}
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
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));
});
data.set("termsJson", JSON.stringify(terms));
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">
{companies.length > 0 && (
<div>
<label className={LABEL}>Company <span className="text-danger">*</span></label>
<select name="companyId" required defaultValue={po.companyId ?? ""} className={INPUT}>
<option value="">Select company</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div className={companies.length > 0 ? "" : "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="vesselId" required defaultValue={po.vesselId ?? ""} className={INPUT}>
<option value="">Select cost centre</option>
{vessels.map((v) => (
<option key={v.id} value={v.id}>{v.code} {v.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
<ManagerAccountSelect
accountId={po.accountId}
accounts={accounts}
/>
</div>
<div>
<label className={LABEL}>Project Code</label>
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT} />
</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>
<DeliveryLocationField options={deliveryOptions} current={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>
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
</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>
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} accent="amber" />
</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>
);
}