diff --git a/App/pelagia-portal/app/(portal)/my-orders/page.tsx b/App/pelagia-portal/app/(portal)/my-orders/page.tsx new file mode 100644 index 0000000..2eab671 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/my-orders/page.tsx @@ -0,0 +1,118 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { formatCurrency, formatDate, PO_STATUS_LABELS, PO_STATUS_VARIANTS } from "@/lib/utils"; +import { PoStatusBadge } from "@/components/po/po-status-badge"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "My Purchase Orders" }; + +export default async function MyOrdersPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const { role, id: userId } = session.user; + if (!["TECHNICAL", "MANNING", "SUPERUSER"].includes(role)) redirect("/dashboard"); + + const pos = await db.purchaseOrder.findMany({ + where: { submitterId: userId }, + orderBy: { updatedAt: "desc" }, + include: { + vessel: { select: { name: true } }, + account: { select: { name: true, code: true } }, + }, + }); + + const open = pos.filter((p) => + ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED"].includes(p.status) + ); + const closed = pos.filter((p) => + ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status) + ); + + return ( +
+
+

My Purchase Orders

+ + + New PO + +
+ + + {closed.length > 0 && } + {pos.length === 0 && ( +
+

You haven't raised any purchase orders yet.

+ + Create your first PO → + +
+ )} +
+ ); +} + +type PoRow = { + id: string; + poNumber: string; + title: string; + status: import("@prisma/client").POStatus; + totalAmount: import("@prisma/client").Prisma.Decimal; + vessel: { name: string }; + account: { name: string; code: string }; + updatedAt: Date; + managerNote: string | null; +}; + +function PoTable({ title, rows, className = "" }: { title: string; rows: PoRow[]; className?: string }) { + if (rows.length === 0) return null; + return ( +
+

{title}

+
+ + + + + + + + + + + + + {rows.map((po) => ( + + + + + + + + + ))} + +
PO NumberTitleVesselStatusAmountUpdated
+ + {po.poNumber} + + + + {po.title} + + {po.managerNote && ( +

+ Note: {po.managerNote} +

+ )} +
{po.vessel.name}{formatCurrency(Number(po.totalAmount))}{formatDate(po.updatedAt)}
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts b/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts new file mode 100644 index 0000000..c1497f9 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts @@ -0,0 +1,55 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { canPerformAction } from "@/lib/po-state-machine"; +import { notify } from "@/lib/notifier"; +import { revalidatePath } from "next/cache"; + +export async function confirmReceipt({ + poId, + notes, +}: { + poId: string; + notes?: string; +}): Promise<{ ok: true } | { error: string }> { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "confirm_receipt", session.user.role)) { + return { error: "You cannot confirm receipt on this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + status: "CLOSED", + closedAt: new Date(), + receipt: notes + ? { create: { storageKey: "", fileName: "no-file", notes } } + : undefined, + actions: { + create: { + actionType: "RECEIPT_CONFIRMED", + actorId: session.user.id, + note: notes ?? null, + }, + }, + }, + }); + + const [managers, accounts] = await Promise.all([ + db.user.findMany({ where: { role: "MANAGER", isActive: true } }), + db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }), + ]); + await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] }); + + revalidatePath(`/po/${poId}`); + revalidatePath("/dashboard"); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx b/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx new file mode 100644 index 0000000..2d0abd1 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx @@ -0,0 +1,41 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { notFound, redirect } from "next/navigation"; +import { ReceiptForm } from "./receipt-form"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Confirm Receipt" }; + +interface Props { + params: Promise<{ id: string }>; +} + +export default async function ReceiptPage({ params }: Props) { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const { id } = await params; + + const po = await db.purchaseOrder.findUnique({ + where: { id }, + select: { id: true, poNumber: true, title: true, status: true, submitterId: true }, + }); + + if (!po) notFound(); + if (po.status !== "PAID_DELIVERED") redirect(`/po/${id}`); + if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") { + redirect(`/po/${id}`); + } + + return ( +
+
+

Confirm Receipt

+

+ {po.poNumber} — {po.title} +

+
+ +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx b/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx new file mode 100644 index 0000000..923ca92 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { confirmReceipt } from "./actions"; +import { FileUploader } from "@/components/po/file-uploader"; +import { uploadAndLinkFiles } from "@/lib/upload-files"; + +export function ReceiptForm({ poId }: { poId: string }) { + const router = useRouter(); + const [notes, setNotes] = useState(""); + const [files, setFiles] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(""); + const result = await confirmReceipt({ poId, notes }); + if ("error" in result) { + setError(result.error); + setSubmitting(false); + return; + } + if (files.length > 0) { + const uploadErr = await uploadAndLinkFiles(poId, files, "receipt"); + if (uploadErr) { + setError(uploadErr.error); + setSubmitting(false); + return; + } + } + router.push(`/po/${poId}`); + router.refresh(); + } + + return ( +
+
+

+ Confirming receipt will close this purchase order. Please verify that all + items have been received before proceeding. +

+ +
+ +