diff --git a/App/pelagia-portal/app/(portal)/history/history-filters.tsx b/App/pelagia-portal/app/(portal)/history/history-filters.tsx new file mode 100644 index 0000000..f3bdd28 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/history/history-filters.tsx @@ -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 ( +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + +
+
+ + +
+
+
+ + {hasFilters && ( + + )} +
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/history/page.tsx b/App/pelagia-portal/app/(portal)/history/page.tsx new file mode 100644 index 0000000..f87c73a --- /dev/null +++ b/App/pelagia-portal/app/(portal)/history/page.tsx @@ -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[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 ( +
+
+

PO History

+
+ + Export PDF + + + Export CSV + +
+
+ + + + + +
+ + + + + + + + + + + + + + {orders.map((po) => ( + + + + + + + + + + ))} + +
PO NumberTitleVesselSubmitterStatusAmountCreated
+ + {po.poNumber} + + {po.title}{po.vessel.name}{po.submitter.name} + + + {formatCurrency(Number(po.totalAmount), po.currency)} + {formatDate(po.createdAt)}
+ {orders.length === 0 && ( +
No purchase orders found.
+ )} +
+ {orders.length === 200 && ( +

Showing first 200 results — refine filters to narrow results.

+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/api/reports/export/route.ts b/App/pelagia-portal/app/api/reports/export/route.ts new file mode 100644 index 0000000..1012d87 --- /dev/null +++ b/App/pelagia-portal/app/api/reports/export/route.ts @@ -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 = { + 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[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) => ` + + ${po.poNumber} + ${po.title} + ${PO_STATUS_LABELS[po.status] ?? po.status} + ${po.vessel.name} + ${po.submitter.name} + ${po.vendor?.name ?? "—"} + ${Number(po.totalAmount).toLocaleString("en-IN", { style: "currency", currency: "INR" })} + ${po.createdAt.toLocaleDateString("en-IN")} + `).join(""); + + const html = ` + + + +PO Export — Pelagia Portal + + + +
+ +
+

Purchase Order Report — Pelagia Portal

+

Generated: ${new Date().toLocaleString("en-IN")} · ${orders.length} orders

+ + + + + + + + ${rows} +
PO NumberTitleStatusVesselSubmitterVendorAmountCreated
+ + +`; + + 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"`, + }, + }); +}