feat(approvals): highlight submitter's edits to manager on resubmission
When a submitter edits and resubmits a PO after the manager requested edits
(EDITS_REQUESTED → MGR_REVIEW), the manager now sees exactly what changed.
Changes:
- edit/actions.ts: before mutating the PO, snapshot the current state
(line items + header fields incl. vessel/account/vendor names) into the
SUBMITTED action's metadata as { editSnapshot: { lineItems, fields } }.
- po-line-items-editor: add `originalItemsLabel` prop so the diff banner
message can be context-specific (manager edit vs. submitter resubmit).
- po-detail: detect the resubmit snapshot from the most recent SUBMITTED
action with editSnapshot metadata; show a "Changes from last review"
amber table listing every header field the submitter changed (title,
cost centre, account, vendor, project code, date required, place of
delivery); pass the resubmit snapshot as originalItems to LineItemsEditor
so changed line items are highlighted with strikethrough on prior values.
Resubmit snapshot takes priority over manager-line-edit diff.
Panel is only visible to MANAGER / SUPERUSER when status is MGR_REVIEW.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4737edcee9
commit
0252e8eab4
3 changed files with 161 additions and 6 deletions
|
|
@ -76,6 +76,59 @@ export async function updatePo(
|
||||||
|
|
||||||
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
|
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
|
||||||
|
|
||||||
|
// Before mutating, snapshot the current PO state so the manager can see
|
||||||
|
// exactly what the submitter changed when they resubmit after edits requested.
|
||||||
|
let resubmitSnapshot: {
|
||||||
|
lineItems: Array<{
|
||||||
|
name: string; description: string | null; quantity: number;
|
||||||
|
unit: string; size: string | null; unitPrice: number; gstRate: number;
|
||||||
|
}>;
|
||||||
|
fields: {
|
||||||
|
title: string;
|
||||||
|
vessel: string | null; vesselId: string;
|
||||||
|
account: string; accountId: string;
|
||||||
|
vendor: string | null; vendorId: string | null;
|
||||||
|
projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null;
|
||||||
|
};
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (isResubmit) {
|
||||||
|
const currentPo = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id: poId },
|
||||||
|
include: {
|
||||||
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
|
vessel: true,
|
||||||
|
account: true,
|
||||||
|
vendor: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (currentPo) {
|
||||||
|
resubmitSnapshot = {
|
||||||
|
lineItems: currentPo.lineItems.map((li) => ({
|
||||||
|
name: li.name,
|
||||||
|
description: li.description,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
unit: li.unit,
|
||||||
|
size: li.size,
|
||||||
|
unitPrice: Number(li.unitPrice),
|
||||||
|
gstRate: Number(li.gstRate),
|
||||||
|
})),
|
||||||
|
fields: {
|
||||||
|
title: currentPo.title,
|
||||||
|
vessel: currentPo.vessel?.name ?? null,
|
||||||
|
vesselId: currentPo.vesselId,
|
||||||
|
account: `${currentPo.account.name} (${currentPo.account.code})`,
|
||||||
|
accountId: currentPo.accountId,
|
||||||
|
vendor: currentPo.vendor?.name ?? null,
|
||||||
|
vendorId: currentPo.vendorId,
|
||||||
|
projectCode: currentPo.projectCode,
|
||||||
|
dateRequired: currentPo.dateRequired?.toISOString() ?? null,
|
||||||
|
placeOfDelivery: currentPo.placeOfDelivery,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.purchaseOrder.update({
|
await db.purchaseOrder.update({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -116,7 +169,11 @@ export async function updatePo(
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
create: isResubmit
|
create: isResubmit
|
||||||
? { actionType: "SUBMITTED", actorId: session.user.id }
|
? {
|
||||||
|
actionType: "SUBMITTED",
|
||||||
|
actorId: session.user.id,
|
||||||
|
...(resubmitSnapshot ? { metadata: { editSnapshot: resubmitSnapshot } } : {}),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { generateDownloadUrl } from "@/lib/storage";
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
type PoWithRelations = {
|
type PoWithRelations = {
|
||||||
|
|
@ -100,9 +101,39 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
const managerEditAction = [...po.actions]
|
const managerEditAction = [...po.actions]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((a) => a.actionType === "MANAGER_LINE_EDIT");
|
.find((a) => a.actionType === "MANAGER_LINE_EDIT");
|
||||||
const originalLineItems = managerEditAction
|
|
||||||
|
// Resubmit snapshot: stored in the most recent SUBMITTED action's metadata
|
||||||
|
// when the submitter resubmits after EDITS_REQUESTED.
|
||||||
|
type ResubmitSnapshot = {
|
||||||
|
lineItems: LineItemInput[];
|
||||||
|
fields: {
|
||||||
|
title: string;
|
||||||
|
vessel: string | null; vesselId: string;
|
||||||
|
account: string; accountId: string;
|
||||||
|
vendor: string | null; vendorId: string | null;
|
||||||
|
projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const resubmitAction = [...po.actions]
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(a) =>
|
||||||
|
a.actionType === "SUBMITTED" &&
|
||||||
|
!!(a.metadata as { editSnapshot?: unknown } | null)?.editSnapshot
|
||||||
|
);
|
||||||
|
const resubmitSnapshot = resubmitAction
|
||||||
|
? (resubmitAction.metadata as { editSnapshot: ResubmitSnapshot }).editSnapshot
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Resubmit snapshot takes priority over manager line edit diff.
|
||||||
|
const originalLineItems: LineItemInput[] | undefined = resubmitSnapshot?.lineItems
|
||||||
|
?? (managerEditAction
|
||||||
? (managerEditAction.metadata as { original: typeof lineItemsForEditor } | null)?.original
|
? (managerEditAction.metadata as { original: typeof lineItemsForEditor } | null)?.original
|
||||||
: undefined;
|
: undefined);
|
||||||
|
|
||||||
|
const lineItemsDiffLabel = resubmitSnapshot
|
||||||
|
? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough."
|
||||||
|
: "Line items were amended by manager. Current values shown; original values shown with strikethrough.";
|
||||||
|
|
||||||
const downloadUrls = await Promise.all(
|
const downloadUrls = await Promise.all(
|
||||||
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
|
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
|
||||||
|
|
@ -170,6 +201,65 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
|
||||||
|
{resubmitSnapshot &&
|
||||||
|
po.status === "MGR_REVIEW" &&
|
||||||
|
(currentRole === "MANAGER" || currentRole === "SUPERUSER") && (() => {
|
||||||
|
const snap = resubmitSnapshot.fields;
|
||||||
|
const currentVessel = po.vessel?.name ?? null;
|
||||||
|
const currentAccount = `${po.account.name} (${po.account.code})`;
|
||||||
|
const currentVendor = po.vendor?.name ?? null;
|
||||||
|
const currentDateRequired = po.dateRequired?.toISOString() ?? null;
|
||||||
|
|
||||||
|
const fieldChanges: { label: string; before: string | null; after: string | null }[] = [];
|
||||||
|
if (snap.title !== po.title)
|
||||||
|
fieldChanges.push({ label: "Title", before: snap.title, after: po.title });
|
||||||
|
if (snap.vesselId !== po.vessel.id)
|
||||||
|
fieldChanges.push({ label: "Cost Centre", before: snap.vessel, after: currentVessel });
|
||||||
|
if (snap.accountId !== po.account.id)
|
||||||
|
fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount });
|
||||||
|
if (snap.vendorId !== (po.vendor?.id ?? null))
|
||||||
|
fieldChanges.push({ label: "Vendor", before: snap.vendor ?? "None", after: currentVendor ?? "None" });
|
||||||
|
if (snap.projectCode !== po.projectCode)
|
||||||
|
fieldChanges.push({ label: "Project Code", before: snap.projectCode ?? "—", after: po.projectCode ?? "—" });
|
||||||
|
if (snap.dateRequired !== currentDateRequired)
|
||||||
|
fieldChanges.push({
|
||||||
|
label: "Date Required",
|
||||||
|
before: snap.dateRequired ? formatDate(snap.dateRequired) : "—",
|
||||||
|
after: po.dateRequired ? formatDate(po.dateRequired) : "—",
|
||||||
|
});
|
||||||
|
if (snap.placeOfDelivery !== po.placeOfDelivery)
|
||||||
|
fieldChanges.push({ label: "Place of Delivery", before: snap.placeOfDelivery ?? "—", after: po.placeOfDelivery ?? "—" });
|
||||||
|
|
||||||
|
if (fieldChanges.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
|
<p className="text-sm font-semibold text-amber-800 mb-2">
|
||||||
|
Submitter updated the following fields after edits were requested
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-amber-200">
|
||||||
|
<th className="pb-1.5 text-left font-medium text-amber-700 w-32">Field</th>
|
||||||
|
<th className="pb-1.5 text-left font-medium text-amber-700 pl-4">Before</th>
|
||||||
|
<th className="pb-1.5 text-left font-medium text-amber-700 pl-4">After</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-amber-100">
|
||||||
|
{fieldChanges.map(({ label, before, after }) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td className="py-1.5 font-medium text-amber-700">{label}</td>
|
||||||
|
<td className="py-1.5 pl-4 text-neutral-500 line-through">{before}</td>
|
||||||
|
<td className="py-1.5 pl-4 font-medium text-amber-900">{after}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Order Details */}
|
{/* Order Details */}
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
|
||||||
|
|
@ -238,7 +328,12 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
{/* Line Items */}
|
{/* Line Items */}
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Line Items</h3>
|
||||||
<LineItemsEditor items={lineItemsForEditor} readOnly originalItems={originalLineItems} />
|
<LineItemsEditor
|
||||||
|
items={lineItemsForEditor}
|
||||||
|
readOnly
|
||||||
|
originalItems={originalLineItems}
|
||||||
|
originalItemsLabel={lineItemsDiffLabel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terms & Conditions */}
|
{/* Terms & Conditions */}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ interface Props {
|
||||||
onChange?: (items: LineItemInput[]) => void;
|
onChange?: (items: LineItemInput[]) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
originalItems?: LineItemInput[];
|
originalItems?: LineItemInput[];
|
||||||
|
/** Label shown in the diff banner when originalItems is provided */
|
||||||
|
originalItemsLabel?: string;
|
||||||
/** When true, show per-row account selector */
|
/** When true, show per-row account selector */
|
||||||
multiAccount?: boolean;
|
multiAccount?: boolean;
|
||||||
accounts?: AccountOption[];
|
accounts?: AccountOption[];
|
||||||
|
|
@ -202,6 +204,7 @@ export function LineItemsEditor({
|
||||||
onChange,
|
onChange,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
originalItems,
|
originalItems,
|
||||||
|
originalItemsLabel,
|
||||||
multiAccount = false,
|
multiAccount = false,
|
||||||
accounts = [],
|
accounts = [],
|
||||||
defaultAccountId,
|
defaultAccountId,
|
||||||
|
|
@ -256,7 +259,7 @@ export function LineItemsEditor({
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{hasDiff && (
|
{hasDiff && (
|
||||||
<p className="mb-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
|
<p className="mb-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
|
||||||
Line items were amended by manager. Current values shown; original values shown with strikethrough.
|
{originalItemsLabel ?? "Line items were amended by manager. Current values shown; original values shown with strikethrough."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue