pelagia-portal/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx
Hardik e308d86e93 feat: Companies — multi-company PO support with admin CRUD and export integration
Schema:
- New Company model (name, gstNumber, address, telephone, mobile, email, invoiceAddress, isActive)
- PurchaseOrder.companyId FK (optional, SET NULL on company delete)
- Migration: 20260530000003_add_company

Admin:
- /admin/companies page with full CRUD (create, edit, deactivate, delete)
- Companies table shows name, GST, contact details, status
- Companies link added to Admin section of sidebar (Briefcase icon)

PO forms (new / edit / import / manager-edit):
- Company dropdown appears at the top of Order Information when companies exist
- Pre-populated with first active company; selection persisted to DB via companyId

Import form:
- parseSheet() now extracts companyName from Excel row 1 (col A)
- Import preview auto-matches detected company name against known companies
- Shows detected name as a hint; user can override before saving

Export (PDF + XLSX):
- Company constants (CO_NAME, CO_ADDR, CO_TEL, INV_ADDR, INV_GST) are now
  derived from the linked Company record when present, falling back to the
  original Pelagia Marine hardcoded defaults when no company is set

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

320 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 { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
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[];
}
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 }: 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">
{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>
<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>
);
}