Adds an "Email to vendor" button on the PO detail (available once approved,
through CLOSED, and again after payment) that opens an Outlook draft addressed
to the vendor's primary contact with a time-limited PDF download link.
Since mailto: can't attach files, the PDF is rendered and stored, and the draft
carries a link (the approach chosen for this issue):
- PdfService/: new standalone Express + Playwright microservice (GstService/
EpfoService pattern) — POST /pdf { url } renders a page to a real PDF via
headless Chromium. SSRF-guarded (shared token + optional origin allowlist).
- export route: accepts a server-only `svc` token (PDF_SERVICE_TOKEN) so
PdfService can fetch /api/po/[id]/export?format=pdf without a user session;
`pdf=1` drops the print button + window.print() auto-trigger.
- lib/pdf-service.ts renderPoPdf(); prepareVendorEmail() server action renders →
uploads to R2 (po-pdf/…) → presigns a 7-day link → returns a mailto draft.
- po-detail: EmailVendorButton, shown when approved + vendor has a contact email.
- Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN; friendly error if unconfigured.
- No DB model/migration. Tests: prepareVendorEmail (6, PdfService/storage mocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
67 lines
2.3 KiB
TypeScript
67 lines
2.3 KiB
TypeScript
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: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
|
documents: { orderBy: { uploadedAt: "desc" } },
|
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
|
receipt: true,
|
|
supersededBy: { select: { id: true, poNumber: true } },
|
|
supersedes: { select: { id: true, poNumber: 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"].includes(session.user.role) && po.submitterId === session.user.id) ||
|
|
["ACCOUNTS", "MANAGER", "SUPERUSER"].includes(session.user.role)
|
|
);
|
|
|
|
const vendors = canProvideVendorId
|
|
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
|
|
: [];
|
|
|
|
const vendorEmail = po.vendor?.contacts?.[0]?.email ?? null;
|
|
|
|
return (
|
|
<div className="max-w-6xl space-y-6">
|
|
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} vendorEmail={vendorEmail} />
|
|
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
|
|
</div>
|
|
);
|
|
}
|