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:
parent
c0ec7716d7
commit
31906ec8bb
3 changed files with 343 additions and 0 deletions
92
App/pelagia-portal/app/(portal)/history/history-filters.tsx
Normal file
92
App/pelagia-portal/app/(portal)/history/history-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
App/pelagia-portal/app/(portal)/history/page.tsx
Normal file
128
App/pelagia-portal/app/(portal)/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
App/pelagia-portal/app/api/reports/export/route.ts
Normal file
123
App/pelagia-portal/app/api/reports/export/route.ts
Normal 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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue