pelagia-portal/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx
Claude (auto-fix) c503f839e8
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
feat(po): make vendor field a searchable combobox
The vendor field on the PO forms was a plain native <select>, forcing
users to scroll the full vendor list. Mirror the item-search UX with a
searchable combobox that filters by vendor name and formal code
(vendorId), case-insensitively. The vendor list is already client-side,
so this is a pure in-memory filter — no API or DB change.

New VendorSelect component (components/ui/vendor-select.tsx) is a
self-contained portal-rendered combobox posting a hidden vendorId input,
so it drops into all three PO forms unchanged on the server:
- po/new/new-po-form
- po/[id]/edit/edit-po-form
- approvals/[id]/manager-edit-po-form

Preserves the optional field, "No vendor selected" empty option, and the
"{name} (CODE)" / "(unverified)" label. Unverified vendors (null code)
remain findable by name. Adds unit tests for the filter logic and
component behaviour.

Fixes #109
2026-06-24 05:16:57 +05:30

290 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 { 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[];
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, 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>
<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>
<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>
);
}