feat(po): PO detail view, vendor ID form and edit/resubmit flow
Detail: order info, vendor (address/GSTIN/contact), line items with GST breakdown, structured T&C, attachments, activity trail, Export PDF/XLSX buttons. Vendor ID form: inline on PO detail when status is VENDOR_ID_PENDING. Edit: pre-populated form for DRAFT and EDITS_REQUESTED; resubmit transitions to MGR_REVIEW.
This commit is contained in:
parent
5a1db32cee
commit
7e12e24af0
7 changed files with 920 additions and 0 deletions
49
App/pelagia-portal/app/(portal)/po/[id]/actions.ts
Normal file
49
App/pelagia-portal/app/(portal)/po/[id]/actions.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canPerformAction } from "@/lib/po-state-machine";
|
||||||
|
import { notify } from "@/lib/notifier";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function provideVendorId({
|
||||||
|
poId,
|
||||||
|
vendorId,
|
||||||
|
}: {
|
||||||
|
poId: string;
|
||||||
|
vendorId: string;
|
||||||
|
}): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
if (!vendorId) return { error: "Please select a vendor with a verified ID." };
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id: poId },
|
||||||
|
include: { submitter: true },
|
||||||
|
});
|
||||||
|
if (!po) return { error: "PO not found" };
|
||||||
|
if (!canPerformAction(po.status, "provide_vendor_id", session.user.role)) {
|
||||||
|
return { error: "You cannot provide a vendor ID for this PO in its current state." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = await db.vendor.findUnique({ where: { id: vendorId } });
|
||||||
|
if (!vendor?.vendorId) return { error: "The selected vendor does not have a verified ID." };
|
||||||
|
|
||||||
|
await db.purchaseOrder.update({
|
||||||
|
where: { id: poId },
|
||||||
|
data: {
|
||||||
|
vendorId,
|
||||||
|
status: "MGR_REVIEW",
|
||||||
|
actions: {
|
||||||
|
create: { actionType: "VENDOR_ID_PROVIDED", actorId: session.user.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
||||||
|
await notify({ event: "VENDOR_ID_PROVIDED", po, recipients: managers });
|
||||||
|
|
||||||
|
revalidatePath(`/po/${poId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
134
App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts
Normal file
134
App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { createPoSchema } from "@/lib/validations/po";
|
||||||
|
import { notify } from "@/lib/notifier";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
function parseLineItems(formData: FormData) {
|
||||||
|
const items = [];
|
||||||
|
let i = 0;
|
||||||
|
while (formData.has(`lineItems[${i}].description`)) {
|
||||||
|
items.push({
|
||||||
|
description: formData.get(`lineItems[${i}].description`) as string,
|
||||||
|
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
|
||||||
|
unit: formData.get(`lineItems[${i}].unit`) as string,
|
||||||
|
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||||||
|
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
||||||
|
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePo(
|
||||||
|
poId: string,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<{ id: string } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
if (!po) return { error: "PO not found" };
|
||||||
|
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) {
|
||||||
|
return { error: "This PO cannot be edited in its current state." };
|
||||||
|
}
|
||||||
|
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
|
||||||
|
return { error: "You can only edit your own purchase orders." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = formData.get("intent") as "save" | "resubmit";
|
||||||
|
|
||||||
|
const parsed = createPoSchema.safeParse({
|
||||||
|
title: formData.get("title"),
|
||||||
|
vesselId: formData.get("vesselId"),
|
||||||
|
accountId: formData.get("accountId"),
|
||||||
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
|
vendorId: formData.get("vendorId") || undefined,
|
||||||
|
piQuotationNo: formData.get("piQuotationNo") || undefined,
|
||||||
|
piQuotationDate: formData.get("piQuotationDate") || undefined,
|
||||||
|
requisitionNo: formData.get("requisitionNo") || undefined,
|
||||||
|
requisitionDate: formData.get("requisitionDate") || undefined,
|
||||||
|
placeOfDelivery: formData.get("placeOfDelivery") || undefined,
|
||||||
|
tcDelivery: formData.get("tcDelivery") || undefined,
|
||||||
|
tcDispatch: formData.get("tcDispatch") || undefined,
|
||||||
|
tcInspection: formData.get("tcInspection") || undefined,
|
||||||
|
tcTransitInsurance: formData.get("tcTransitInsurance") || undefined,
|
||||||
|
tcPaymentTerms: formData.get("tcPaymentTerms") || undefined,
|
||||||
|
tcOthers: formData.get("tcOthers") || undefined,
|
||||||
|
lineItems: parseLineItems(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
const total = data.lineItems.reduce(
|
||||||
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
|
||||||
|
|
||||||
|
await db.purchaseOrder.update({
|
||||||
|
where: { id: poId },
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
vesselId: data.vesselId,
|
||||||
|
accountId: data.accountId,
|
||||||
|
vendorId: data.vendorId ?? null,
|
||||||
|
projectCode: data.projectCode ?? null,
|
||||||
|
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
|
||||||
|
piQuotationNo: data.piQuotationNo ?? null,
|
||||||
|
piQuotationDate: data.piQuotationDate ? new Date(data.piQuotationDate) : null,
|
||||||
|
requisitionNo: data.requisitionNo ?? null,
|
||||||
|
requisitionDate: data.requisitionDate ? new Date(data.requisitionDate) : null,
|
||||||
|
placeOfDelivery: data.placeOfDelivery ?? null,
|
||||||
|
tcDelivery: data.tcDelivery ?? null,
|
||||||
|
tcDispatch: data.tcDispatch ?? null,
|
||||||
|
tcInspection: data.tcInspection ?? null,
|
||||||
|
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
||||||
|
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
||||||
|
tcOthers: data.tcOthers ?? null,
|
||||||
|
totalAmount: total,
|
||||||
|
status: isResubmit ? "MGR_REVIEW" : "DRAFT",
|
||||||
|
submittedAt: isResubmit ? new Date() : po.submittedAt,
|
||||||
|
lineItems: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: data.lineItems.map((item, idx) => ({
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
size: item.size ?? null,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
totalPrice: item.quantity * item.unitPrice,
|
||||||
|
gstRate: item.gstRate,
|
||||||
|
sortOrder: idx,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
create: isResubmit
|
||||||
|
? { actionType: "SUBMITTED", actorId: session.user.id }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isResubmit) {
|
||||||
|
const [fullPo, managers] = await Promise.all([
|
||||||
|
db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true } }),
|
||||||
|
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
|
||||||
|
]);
|
||||||
|
if (fullPo) {
|
||||||
|
await notify({ event: "PO_SUBMITTED", po: fullPo, recipients: [fullPo.submitter, ...managers] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/po/${poId}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { id: poId };
|
||||||
|
}
|
||||||
253
App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx
Normal file
253
App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { updatePo } from "./actions";
|
||||||
|
import type { Vessel, Account, Vendor, PurchaseOrder, POLineItem } from "@prisma/client";
|
||||||
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
type PoWithItems = PurchaseOrder & { lineItems: POLineItem[] };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
po: PoWithItems;
|
||||||
|
vessels: Vessel[];
|
||||||
|
accounts: Account[];
|
||||||
|
vendors: Vendor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
|
po.lineItems.map((li) => ({
|
||||||
|
description: li.description,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
unit: li.unit,
|
||||||
|
size: li.size ?? undefined,
|
||||||
|
unitPrice: Number(li.unitPrice),
|
||||||
|
gstRate: Number((li as POLineItem & { gstRate: unknown }).gstRate ?? 0.18),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||||
|
|
||||||
|
async function handleSubmit(intent: "save" | "resubmit") {
|
||||||
|
setSubmitting(intent);
|
||||||
|
setError("");
|
||||||
|
const form = document.getElementById("edit-po-form") as HTMLFormElement;
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.set("intent", intent);
|
||||||
|
lineItems.forEach((item, i) => {
|
||||||
|
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 updatePo(po.id, data);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setSubmitting(null);
|
||||||
|
} else {
|
||||||
|
router.push(`/po/${result.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateValue = po.dateRequired
|
||||||
|
? new Date(po.dateRequired).toISOString().split("T")[0]
|
||||||
|
: "";
|
||||||
|
const piDateValue = (po as PurchaseOrder & { piQuotationDate: Date | null }).piQuotationDate
|
||||||
|
? new Date((po as PurchaseOrder & { piQuotationDate: Date | null }).piQuotationDate!).toISOString().split("T")[0]
|
||||||
|
: "";
|
||||||
|
const reqDateValue = (po as PurchaseOrder & { requisitionDate: Date | null }).requisitionDate
|
||||||
|
? new Date((po as PurchaseOrder & { requisitionDate: Date | null }).requisitionDate!).toISOString().split("T")[0]
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const extPo = po as PurchaseOrder & {
|
||||||
|
piQuotationNo: string | null;
|
||||||
|
requisitionNo: string | null;
|
||||||
|
placeOfDelivery: string | null;
|
||||||
|
tcDelivery: string | null;
|
||||||
|
tcDispatch: string | null;
|
||||||
|
tcInspection: string | null;
|
||||||
|
tcTransitInsurance: string | null;
|
||||||
|
tcPaymentTerms: string | null;
|
||||||
|
tcOthers: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
{canResubmit && (
|
||||||
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-warning-700">Edits requested</p>
|
||||||
|
{po.managerNote && (
|
||||||
|
<p className="mt-1 text-sm text-warning-700 italic">"{po.managerNote}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order Information */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Title <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input name="title" required defaultValue={po.title} className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Vessel <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="vesselId" required defaultValue={po.vesselId} className={INPUT_CLS}>
|
||||||
|
<option value="">Select vessel…</option>
|
||||||
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Account / Cost Centre <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="accountId" required defaultValue={po.accountId} className={INPUT_CLS}>
|
||||||
|
<option value="">Select account…</option>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
|
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
|
<input name="dateRequired" type="date" defaultValue={dateValue} className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quotation Reference */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Quotation Reference</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation No.</label>
|
||||||
|
<input name="piQuotationNo" defaultValue={extPo.piQuotationNo ?? ""} className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation Date</label>
|
||||||
|
<input name="piQuotationDate" type="date" defaultValue={piDateValue} className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Requisition */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Requisition</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Vessel / Office Requisition No.</label>
|
||||||
|
<input name="requisitionNo" defaultValue={extPo.requisitionNo ?? ""} className={INPUT_CLS} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Requisition Date</label>
|
||||||
|
<input name="requisitionDate" type="date" defaultValue={reqDateValue} className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Delivery */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
|
<textarea name="placeOfDelivery" rows={2} className={INPUT_CLS} defaultValue={extPo.placeOfDelivery ?? ""} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
|
<LineItemsEditor items={lineItems} onChange={setLineItems} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Vendor */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
|
||||||
|
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Terms & Conditions */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms & Conditions</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
|
||||||
|
<span className="font-medium text-neutral-600">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-sm font-medium text-neutral-500 text-right">{n}.</span>
|
||||||
|
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
defaultValue={extPo[key] ?? TC_DEFAULTS[key]}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right mt-2.5">7.</span>
|
||||||
|
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
|
||||||
|
<textarea
|
||||||
|
name="tcOthers"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={extPo.tcOthers ?? TC_DEFAULTS.tcOthers}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<button type="button" onClick={() => router.back()}
|
||||||
|
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 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleSubmit("save")} disabled={!!submitting}
|
||||||
|
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 transition-colors">
|
||||||
|
{submitting === "save" ? "Saving…" : "Save Draft"}
|
||||||
|
</button>
|
||||||
|
{canResubmit && (
|
||||||
|
<button type="button" onClick={() => handleSubmit("resubmit")} disabled={!!submitting}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors">
|
||||||
|
{submitting === "resubmit" ? "Resubmitting…" : "Resubmit for Approval"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
App/pelagia-portal/app/(portal)/po/[id]/edit/page.tsx
Normal file
47
App/pelagia-portal/app/(portal)/po/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { EditPoForm } from "./edit-po-form";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Edit Purchase Order" };
|
||||||
|
|
||||||
|
export default async function EditPoPage({ params }: Props) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!po) notFound();
|
||||||
|
|
||||||
|
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) redirect(`/po/${id}`);
|
||||||
|
|
||||||
|
const canEdit =
|
||||||
|
po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
|
if (!canEdit) redirect(`/po/${id}`);
|
||||||
|
|
||||||
|
const [vessels, accounts, vendors] = await Promise.all([
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
|
||||||
|
</div>
|
||||||
|
<EditPoForm po={po} vessels={vessels} accounts={accounts} vendors={vendors} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
App/pelagia-portal/app/(portal)/po/[id]/page.tsx
Normal file
61
App/pelagia-portal/app/(portal)/po/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
|
import { VendorIdForm } from "./vendor-id-form";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const po = await db.purchaseOrder.findUnique({ where: { id }, select: { poNumber: true } });
|
||||||
|
return { title: po ? `PO ${po.poNumber}` : "Purchase Order" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PoDetailPage({ params }: Props) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
submitter: true,
|
||||||
|
vessel: true,
|
||||||
|
account: true,
|
||||||
|
vendor: true,
|
||||||
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
|
documents: { orderBy: { uploadedAt: "desc" } },
|
||||||
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||||
|
receipt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!po) notFound();
|
||||||
|
|
||||||
|
// Submitters can only view their own POs (unless they have view_all_pos)
|
||||||
|
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(
|
||||||
|
session.user.role
|
||||||
|
);
|
||||||
|
if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard");
|
||||||
|
|
||||||
|
const canProvideVendorId =
|
||||||
|
po.status === "VENDOR_ID_PENDING" &&
|
||||||
|
["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"].includes(session.user.role) &&
|
||||||
|
(po.submitterId === session.user.id || ["MANAGER", "SUPERUSER"].includes(session.user.role));
|
||||||
|
|
||||||
|
const vendors = canProvideVendorId
|
||||||
|
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} />
|
||||||
|
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
App/pelagia-portal/app/(portal)/po/[id]/vendor-id-form.tsx
Normal file
65
App/pelagia-portal/app/(portal)/po/[id]/vendor-id-form.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { provideVendorId } from "./actions";
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vendorId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorIdForm({ poId, vendors }: { poId: string; vendors: Vendor[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedVendorId, setSelectedVendorId] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const verifiedVendors = vendors.filter((v) => v.vendorId);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedVendorId) { setError("Please select a vendor."); return; }
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await provideVendorId({ poId, vendorId: selectedVendorId });
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-primary-100 bg-primary-50 p-5">
|
||||||
|
<p className="text-sm font-medium text-primary-800 mb-3">
|
||||||
|
A vendor ID is required before this PO can be approved. Select a verified vendor below.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={selectedVendorId}
|
||||||
|
onChange={(e) => setSelectedVendorId(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||||
|
>
|
||||||
|
<option value="">Select verified vendor…</option>
|
||||||
|
{verifiedVendors.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name} ({v.vendorId})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <span className="text-xs text-danger-700">{error}</span>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{pending ? "Submitting…" : "Provide Vendor ID"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{verifiedVendors.length === 0 && (
|
||||||
|
<p className="mt-2 text-xs text-primary-700">
|
||||||
|
No verified vendors found. Ask an Admin to add one in the Vendor Registry.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
App/pelagia-portal/components/po/po-detail.tsx
Normal file
311
App/pelagia-portal/components/po/po-detail.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
|
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
|
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
type PoWithRelations = {
|
||||||
|
id: string;
|
||||||
|
poNumber: string;
|
||||||
|
title: string;
|
||||||
|
status: import("@prisma/client").POStatus;
|
||||||
|
totalAmount: import("@prisma/client").Prisma.Decimal;
|
||||||
|
currency: string;
|
||||||
|
projectCode: string | null;
|
||||||
|
dateRequired: Date | null;
|
||||||
|
managerNote: string | null;
|
||||||
|
paymentRef: string | null;
|
||||||
|
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;
|
||||||
|
createdAt: Date;
|
||||||
|
submittedAt: Date | null;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
paidAt: Date | null;
|
||||||
|
closedAt: Date | null;
|
||||||
|
submitter: { id: string; name: string; email: string };
|
||||||
|
vessel: { id: string; name: string };
|
||||||
|
account: { id: string; name: string; code: string };
|
||||||
|
vendor: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vendorId: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
gstin?: string | null;
|
||||||
|
contactName?: string | null;
|
||||||
|
contactMobile?: string | null;
|
||||||
|
contactEmail?: string | null;
|
||||||
|
} | null;
|
||||||
|
lineItems: {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: import("@prisma/client").Prisma.Decimal;
|
||||||
|
unit: string;
|
||||||
|
size?: string | null;
|
||||||
|
unitPrice: import("@prisma/client").Prisma.Decimal;
|
||||||
|
totalPrice: import("@prisma/client").Prisma.Decimal;
|
||||||
|
gstRate?: import("@prisma/client").Prisma.Decimal | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}[];
|
||||||
|
documents: { id: string; fileName: string; fileSize: number; storageKey: string; uploadedAt: Date }[];
|
||||||
|
actions: { id: string; actionType: string; note: string | null; metadata: import("@prisma/client").Prisma.JsonValue; createdAt: Date; actor: { name: string } }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
po: PoWithRelations;
|
||||||
|
currentUserId: string;
|
||||||
|
currentRole: Role;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
CREATED: "Created",
|
||||||
|
SUBMITTED: "Submitted for review",
|
||||||
|
APPROVED: "Approved",
|
||||||
|
APPROVED_WITH_NOTE: "Approved with note",
|
||||||
|
REJECTED: "Rejected",
|
||||||
|
EDITS_REQUESTED: "Edits requested",
|
||||||
|
VENDOR_ID_REQUESTED: "Vendor ID requested",
|
||||||
|
VENDOR_ID_PROVIDED: "Vendor ID provided",
|
||||||
|
PAYMENT_SENT: "Payment confirmed",
|
||||||
|
RECEIPT_CONFIRMED: "Receipt confirmed",
|
||||||
|
CLOSED: "Closed",
|
||||||
|
MANAGER_LINE_EDIT: "Manager amended line items",
|
||||||
|
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
||||||
|
const lineItemsForEditor = po.lineItems.map((li) => ({
|
||||||
|
description: li.description,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
unit: li.unit,
|
||||||
|
size: li.size ?? undefined,
|
||||||
|
unitPrice: Number(li.unitPrice),
|
||||||
|
gstRate: li.gstRate != null ? Number(li.gstRate) : 0.18,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const managerEditAction = [...po.actions]
|
||||||
|
.reverse()
|
||||||
|
.find((a) => a.actionType === "MANAGER_LINE_EDIT");
|
||||||
|
const originalLineItems = managerEditAction
|
||||||
|
? (managerEditAction.metadata as { original: typeof lineItemsForEditor } | null)?.original
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const downloadUrls = await Promise.all(
|
||||||
|
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
|
||||||
|
);
|
||||||
|
|
||||||
|
const canConfirmReceipt =
|
||||||
|
po.status === "PAID_DELIVERED" &&
|
||||||
|
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
|
||||||
|
!readOnly;
|
||||||
|
|
||||||
|
// Find the approver from actions
|
||||||
|
const approvalAction = [...po.actions]
|
||||||
|
.reverse()
|
||||||
|
.find((a) => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="font-mono text-sm text-neutral-500">{po.poNumber}</span>
|
||||||
|
<PoStatusBadge status={po.status} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">{po.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{["DRAFT", "EDITS_REQUESTED"].includes(po.status) &&
|
||||||
|
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
|
||||||
|
!readOnly && (
|
||||||
|
<Link
|
||||||
|
href={`/po/${po.id}/edit`}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`/api/po/${po.id}/export?format=pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/api/po/${po.id}/export?format=xlsx`}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Export XLSX
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manager note banner */}
|
||||||
|
{po.managerNote && (
|
||||||
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-warning-700 mb-0.5">Manager note</p>
|
||||||
|
<p className="text-sm text-warning-700">{po.managerNote}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order Details */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
|
<div><dt className="text-neutral-500">Vessel</dt><dd className="font-medium text-neutral-900">{po.vessel.name}</dd></div>
|
||||||
|
<div><dt className="text-neutral-500">Account / Budget Head</dt><dd className="font-medium text-neutral-900">{po.account.name} ({po.account.code})</dd></div>
|
||||||
|
<div><dt className="text-neutral-500">Requested By</dt><dd className="font-medium text-neutral-900">{po.submitter.name}</dd></div>
|
||||||
|
{approvalAction && (
|
||||||
|
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
|
||||||
|
)}
|
||||||
|
{po.projectCode && <div><dt className="text-neutral-500">Project Code</dt><dd className="font-medium text-neutral-900">{po.projectCode}</dd></div>}
|
||||||
|
{po.dateRequired && <div><dt className="text-neutral-500">Delivery Date Required</dt><dd className="font-medium text-neutral-900">{formatDate(po.dateRequired)}</dd></div>}
|
||||||
|
{po.piQuotationNo && <div><dt className="text-neutral-500">PI / Quotation No.</dt><dd className="font-medium text-neutral-900">{po.piQuotationNo}</dd></div>}
|
||||||
|
{po.piQuotationDate && <div><dt className="text-neutral-500">PI / Quotation Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.piQuotationDate)}</dd></div>}
|
||||||
|
{po.requisitionNo && <div><dt className="text-neutral-500">Requisition No.</dt><dd className="font-medium text-neutral-900">{po.requisitionNo}</dd></div>}
|
||||||
|
{po.requisitionDate && <div><dt className="text-neutral-500">Requisition Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.requisitionDate)}</dd></div>}
|
||||||
|
{po.paymentRef && <div><dt className="text-neutral-500">Payment Ref</dt><dd className="font-mono text-sm text-neutral-900">{po.paymentRef}</dd></div>}
|
||||||
|
</dl>
|
||||||
|
{po.placeOfDelivery && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-neutral-100 text-sm">
|
||||||
|
<dt className="text-neutral-500 mb-0.5">Place of Delivery</dt>
|
||||||
|
<dd className="font-medium text-neutral-900 whitespace-pre-wrap">{po.placeOfDelivery}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor */}
|
||||||
|
{po.vendor ? (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Vendor</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><dt className="text-neutral-500">Name</dt><dd className="font-medium text-neutral-900">{po.vendor.name}</dd></div>
|
||||||
|
<div><dt className="text-neutral-500">Vendor ID</dt><dd className="font-mono">{po.vendor.vendorId ?? <span className="text-warning-700 italic">Not assigned</span>}</dd></div>
|
||||||
|
{po.vendor.gstin && <div><dt className="text-neutral-500">GSTIN</dt><dd className="font-mono text-sm">{po.vendor.gstin}</dd></div>}
|
||||||
|
{po.vendor.address && <div className="col-span-2"><dt className="text-neutral-500">Address</dt><dd className="font-medium text-neutral-900 whitespace-pre-wrap">{po.vendor.address}</dd></div>}
|
||||||
|
{(po.vendor.contactName || po.vendor.contactMobile || po.vendor.contactEmail) && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<dt className="text-neutral-500">Contact</dt>
|
||||||
|
<dd className="font-medium text-neutral-900">
|
||||||
|
{[po.vendor.contactName, po.vendor.contactMobile, po.vendor.contactEmail].filter(Boolean).join(" · ")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
|
<p className="text-sm text-warning-700">No vendor assigned to this PO.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Line Items</h3>
|
||||||
|
<LineItemsEditor items={lineItemsForEditor} readOnly originalItems={originalLineItems} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms & Conditions */}
|
||||||
|
{(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms & Conditions</h3>
|
||||||
|
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<span className="shrink-0 font-medium text-neutral-500">1.</span>
|
||||||
|
<span>{TC_FIXED_LINE}</span>
|
||||||
|
</li>
|
||||||
|
{([
|
||||||
|
{ n: 2, label: "DELIVERY", value: po.tcDelivery },
|
||||||
|
{ n: 3, label: "DISPATCH INSTRUCTIONS", value: po.tcDispatch },
|
||||||
|
{ n: 4, label: "INSPECTION", value: po.tcInspection },
|
||||||
|
{ n: 5, label: "TRANSIT INSURANCE", value: po.tcTransitInsurance },
|
||||||
|
{ n: 6, label: "PAYMENT TERMS", value: po.tcPaymentTerms },
|
||||||
|
{ n: 7, label: "OTHERS", value: po.tcOthers },
|
||||||
|
] as const).filter(({ value }) => value).map(({ n, label, value }) => (
|
||||||
|
<li key={n} className="flex gap-2">
|
||||||
|
<span className="shrink-0 font-medium text-neutral-500">{n}.</span>
|
||||||
|
<span><span className="font-medium">{label}:</span> {value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
{po.documents.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{po.documents.map((doc, i) => (
|
||||||
|
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||||||
|
<a
|
||||||
|
href={downloadUrls[i]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{doc.fileName}
|
||||||
|
</a>
|
||||||
|
<span className="text-neutral-400 text-xs">
|
||||||
|
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm receipt CTA */}
|
||||||
|
{canConfirmReceipt && (
|
||||||
|
<div className="rounded-lg border border-success-100 bg-success-50 p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-success-700">Payment confirmed</p>
|
||||||
|
<p className="text-sm text-success-700 mt-0.5">Please confirm that you have received all items.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/po/${po.id}/receipt`}
|
||||||
|
className="rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90"
|
||||||
|
>
|
||||||
|
Confirm Receipt
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit trail */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Activity</h3>
|
||||||
|
<ol className="relative border-l border-neutral-200 ml-2 space-y-4">
|
||||||
|
{po.actions.map((action) => (
|
||||||
|
<li key={action.id} className="pl-5">
|
||||||
|
<div className="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border-2 border-white bg-neutral-400" />
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
{ACTION_LABELS[action.actionType] ?? action.actionType}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neutral-400">by {action.actor.name}</span>
|
||||||
|
<span className="text-xs text-neutral-400 ml-auto">{formatDateTime(action.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{action.note && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 italic">"{action.note}"</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue