feat(history): PO history list with date/vessel/status filters and bulk CSV and PDF export

Full audit list of all POs (latest 200). Filters: date range, vessel, status.
CSV export respects active filters. PDF export renders print-optimised HTML table.
This commit is contained in:
Hardik 2026-05-06 00:15:33 +05:30
parent c0ec7716d7
commit 31906ec8bb
3 changed files with 343 additions and 0 deletions

View file

@ -0,0 +1,92 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
const STATUSES = [
{ value: "", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SUBMITTED", label: "Submitted" },
{ value: "MGR_REVIEW", label: "Pending Approval" },
{ value: "VENDOR_ID_PENDING", label: "Vendor ID Pending" },
{ value: "EDITS_REQUESTED", label: "Edits Requested" },
{ value: "MGR_APPROVED", label: "Approved" },
{ value: "SENT_FOR_PAYMENT", label: "Sent for Payment" },
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
{ value: "CLOSED", label: "Closed" },
{ value: "REJECTED", label: "Rejected" },
];
interface Props {
vessels: { id: string; name: string }[];
}
export function HistoryFilters({ vessels }: Props) {
const router = useRouter();
const sp = useSearchParams();
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [status, setStatus] = useState(sp.get("status") ?? "");
function apply() {
const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
if (vesselId) params.set("vesselId", vesselId);
if (status) params.set("status", status);
router.push(`/history?${params.toString()}`);
}
function clear() {
setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
router.push("/history");
}
const hasFilters = dateFrom || dateTo || vesselId || status;
return (
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">From</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
className="w-full 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">To</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="w-full 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Vessel</label>
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
className="w-full 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="">All vessels</option>
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value)}
className="w-full 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">
{STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<button onClick={apply}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 transition-colors">
Apply
</button>
{hasFilters && (
<button onClick={clear}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50 transition-colors">
Clear
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,128 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import Link from "next/link";
import { formatCurrency, formatDate, PO_STATUS_LABELS } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters";
import { Suspense } from "react";
import type { Metadata } from "next";
import type { POStatus } from "@prisma/client";
export const metadata: Metadata = { title: "History" };
interface Props {
searchParams: Promise<{
dateFrom?: string;
dateTo?: string;
vesselId?: string;
status?: string;
}>;
}
export default async function HistoryPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
const { dateFrom, dateTo, vesselId, status } = await searchParams;
const where: Parameters<typeof db.purchaseOrder.findMany>[0]["where"] = {};
if (dateFrom) where.createdAt = { ...where.createdAt, gte: new Date(dateFrom) };
if (dateTo) {
const end = new Date(dateTo);
end.setDate(end.getDate() + 1);
where.createdAt = { ...where.createdAt, lt: end };
}
if (vesselId) where.vesselId = vesselId;
if (status) where.status = status as POStatus;
const [orders, vessels] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
orderBy: { createdAt: "desc" },
take: 200,
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
const exportParams = new URLSearchParams({ format: "csv" });
if (dateFrom) exportParams.set("dateFrom", dateFrom);
if (dateTo) exportParams.set("dateTo", dateTo);
if (vesselId) exportParams.set("vesselId", vesselId);
if (status) exportParams.set("status", status);
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">PO History</h1>
<div className="flex items-center gap-2">
<a
href={`/api/reports/export?${exportParams.toString()}&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 transition-colors"
>
Export PDF
</a>
<a
href={`/api/reports/export?${exportParams.toString()}`}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Export CSV
</a>
</div>
</div>
<Suspense>
<HistoryFilters vessels={vessels} />
</Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vessel</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{orders.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
{po.poNumber}
</Link>
</td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3">
<PoStatusBadge status={po.status} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs">
{formatCurrency(Number(po.totalAmount), po.currency)}
</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(po.createdAt)}</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 && (
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
)}
</div>
{orders.length === 200 && (
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results refine filters to narrow results.</p>
)}
</div>
);
}

View file

@ -0,0 +1,123 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
import type { POStatus } from "@prisma/client";
const PO_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed",
};
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role, "export_reports")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const sp = request.nextUrl.searchParams;
const format = sp.get("format") ?? "csv";
const dateFrom = sp.get("dateFrom");
const dateTo = sp.get("dateTo");
const vesselId = sp.get("vesselId");
const status = sp.get("status");
const where: Parameters<typeof db.purchaseOrder.findMany>[0]["where"] = {};
if (dateFrom) where.createdAt = { ...where.createdAt, gte: new Date(dateFrom) };
if (dateTo) {
const end = new Date(dateTo);
end.setDate(end.getDate() + 1);
where.createdAt = { ...where.createdAt, lt: end };
}
if (vesselId) where.vesselId = vesselId;
if (status) where.status = status as POStatus;
const orders = await db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true, vendor: true },
orderBy: { createdAt: "desc" },
});
if (format === "pdf") {
const rows = orders.map((po) => `
<tr>
<td>${po.poNumber}</td>
<td>${po.title}</td>
<td>${PO_STATUS_LABELS[po.status] ?? po.status}</td>
<td>${po.vessel.name}</td>
<td>${po.submitter.name}</td>
<td>${po.vendor?.name ?? "—"}</td>
<td style="text-align:right">${Number(po.totalAmount).toLocaleString("en-IN", { style: "currency", currency: "INR" })}</td>
<td>${po.createdAt.toLocaleDateString("en-IN")}</td>
</tr>`).join("");
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>PO Export Pelagia Portal</title>
<style>
body { font-family: Arial, sans-serif; font-size: 11px; margin: 20px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p { font-size: 10px; color: #555; margin-bottom: 12px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f0f0f0; border: 1px solid #ccc; padding: 5px 8px; text-align: left; font-size: 10px; }
td { border: 1px solid #ddd; padding: 4px 8px; vertical-align: top; }
tr:nth-child(even) td { background: #fafafa; }
.no-print { margin-bottom: 12px; }
@media print { .no-print { display: none; } }
</style>
</head>
<body>
<div class="no-print">
<button onclick="window.print()" style="padding:6px 16px;font-size:13px;cursor:pointer;">Print / Save as PDF</button>
</div>
<h1>Purchase Order Report Pelagia Portal</h1>
<p>Generated: ${new Date().toLocaleString("en-IN")} · ${orders.length} orders</p>
<table>
<thead>
<tr>
<th>PO Number</th><th>Title</th><th>Status</th><th>Vessel</th>
<th>Submitter</th><th>Vendor</th><th style="text-align:right">Amount</th><th>Created</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<script>window.onload = function() { window.print(); };</script>
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Default: CSV
const headers = ["PO Number", "Title", "Status", "Vessel", "Account", "Vendor", "Submitter", "Amount", "Currency", "Created"];
const csvRows = orders.map((po) => [
po.poNumber,
`"${po.title.replace(/"/g, '""')}"`,
po.status,
po.vessel.name,
po.account.name,
po.vendor?.name ?? "",
po.submitter.name,
po.totalAmount.toString(),
po.currency,
po.createdAt.toISOString(),
]);
const csv = [headers.join(","), ...csvRows.map((r) => r.join(","))].join("\n");
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="pelagia-po-export-${Date.now()}.csv"`,
},
});
}