fix(export): gate PDF/XLSX on manager-approved status; drop submitter-name fallback

- Export API returns 403 for any PO not yet approved (DRAFT, SUBMITTED,
  MGR_REVIEW, EDITS_REQUESTED, VENDOR_ID_PENDING, REJECTED) — only
  MGR_APPROVED, SENT_FOR_PAYMENT, PAID_DELIVERED, PARTIALLY_CLOSED and
  CLOSED are exportable.
- The submitter's name is no longer used as a signatory fallback; since
  export is blocked until after manager approval an approver always exists.
- Export PDF / Export XLSX buttons are hidden in po-detail for pre-approval
  statuses, so users never encounter the 403 through normal UI flows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-16 20:50:26 +05:30
parent 340a3dcce0
commit 8322f33880
2 changed files with 31 additions and 18 deletions

View file

@ -47,6 +47,16 @@ export async function GET(request: NextRequest, { params }: Props) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document.
// The submitter's signature is never embedded; only the approving manager's signature is used.
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
if (!EXPORTABLE_STATUSES.includes(po.status)) {
return NextResponse.json(
{ error: "Export is only available for approved purchase orders." },
{ status: 403 }
);
}
const format = request.nextUrl.searchParams.get("format") ?? "pdf"; const format = request.nextUrl.searchParams.get("format") ?? "pdf";
// ── Computed data ───────────────────────────────────────────────────────── // ── Computed data ─────────────────────────────────────────────────────────
@ -386,10 +396,10 @@ export async function GET(request: NextRequest, { params }: Props) {
}); });
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } }); sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
} else { } else {
sc(SIG_ROW, 1, approvedBy || po.submitter.name, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC }); sc(SIG_ROW, 1, approvedBy, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
} }
ws.mergeCells(`A${SIG_ROW}:D${SIG_ROW}`); ws.mergeCells(`A${SIG_ROW}:D${SIG_ROW}`);
sc(SIG_ROW + 1, 1, approvedBy || po.submitter.name, { font: fBold, border: { left: thin(), right: thin() }, align: alignC }); sc(SIG_ROW + 1, 1, approvedBy, { font: fBold, border: { left: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 1}:D${SIG_ROW + 1}`); ws.mergeCells(`A${SIG_ROW + 1}:D${SIG_ROW + 1}`);
sc(SIG_ROW + 2, 1, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC }); sc(SIG_ROW + 2, 1, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 2}:D${SIG_ROW + 2}`); ws.mergeCells(`A${SIG_ROW + 2}:D${SIG_ROW + 2}`);
@ -675,10 +685,10 @@ export async function GET(request: NextRequest, { params }: Props) {
<div class="sig-box"> <div class="sig-box">
${signatureBase64 ${signatureBase64
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />` ? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
: `<div class="sig-name">${approvedBy || po.submitter.name}</div>` : `<div class="sig-name">${approvedBy}</div>`
} }
<div> <div>
<div class="sig-sub" style="font-weight:bold">${approvedBy || po.submitter.name}</div> <div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
<div class="sig-sub">Authorized Signatory &amp; Stamp</div> <div class="sig-sub">Authorized Signatory &amp; Stamp</div>
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div> <div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
</div> </div>

View file

@ -183,20 +183,23 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
!readOnly && ( !readOnly && (
<DiscardDraftButton poId={po.id} /> <DiscardDraftButton poId={po.id} />
)} )}
<a {/* Export buttons — only available once the PO has been approved by a manager */}
href={`/api/po/${po.id}/export?format=pdf`} {["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
target="_blank" <a
rel="noopener noreferrer" href={`/api/po/${po.id}/export?format=pdf`}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" target="_blank"
> rel="noopener noreferrer"
Export PDF className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
</a> >
<a Export PDF
href={`/api/po/${po.id}/export?format=xlsx`} </a>
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" <a
> href={`/api/po/${po.id}/export?format=xlsx`}
Export 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"
</a> >
Export XLSX
</a>
</>)}
</div> </div>
</div> </div>