From 43f08615910558ba2a6599b47bfc3209e2bbd2f1 Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 9 May 2026 18:52:51 +0530 Subject: [PATCH] feat: manager PO creation, vendor management, import from Excel, item fuzzy search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permissions & access: - MANAGER gains create_po, submit_po, edit_own_draft_po, view_own_pos, manage_vendors - ACCOUNTS gains manage_vendors - ACCOUNTS added to provide_vendor_id state transition - MANAGER added to DRAFT/EDITS_REQUESTED submit allowed roles - canProvideVendorId now includes ACCOUNTS and any MANAGER/SUPERUSER Vendor required for approval: - approvepo() now returns error if po.vendorId is null - Approval page shows danger banner when vendor is missing Navigation: - MANAGER gets "New PO", "My Purchase Orders", "Import PO", "Vendors" nav items - ACCOUNTS gets "Vendors" nav item Seed data: - Vendors: 12 total (up from 3), with GST, address, contact details - Products: 25 total (up from 4), with lastPrice pre-populated Product fuzzy search in line items editor: - Typing ≥2 chars in description fetches /api/products/search?q= - Dropdown shows code, name, description, last price - Selecting a product auto-fills description and unit price - Linked items show a "✓ linked" indicator - productId passed through FormData to createPo action and stored on POLineItem Excel PO import (/po/import): - MANAGER, SUPERUSER, ADMIN can access - Uploads .xlsx file to /api/po/import which parses the Pelagia PO format - Extracts vendor, line items, quotation ref, T&C, delivery address - Preview step: user selects vessel + account, auto-matches vendor by name - Confirmed import creates PO in DRAFT status Co-Authored-By: Claude Sonnet 4.6 --- .../app/(portal)/approvals/[id]/actions.ts | 4 + .../app/(portal)/approvals/[id]/page.tsx | 9 + .../app/(portal)/my-orders/page.tsx | 2 +- .../app/(portal)/po/[id]/page.tsx | 6 +- .../app/(portal)/po/import/actions.ts | 79 ++++ .../app/(portal)/po/import/import-form.tsx | 296 ++++++++++++++ .../app/(portal)/po/import/page.tsx | 34 ++ .../app/(portal)/po/new/actions.ts | 3 + .../app/(portal)/po/new/new-po-form.tsx | 1 + App/pelagia-portal/app/api/po/import/route.ts | 156 ++++++++ .../app/api/products/search/route.ts | 32 ++ .../components/layout/sidebar.tsx | 7 +- .../components/po/po-line-items-editor.tsx | 123 +++++- App/pelagia-portal/lib/permissions.ts | 7 +- App/pelagia-portal/lib/po-state-machine.ts | 6 +- App/pelagia-portal/lib/validations/po.ts | 1 + App/pelagia-portal/prisma/seed.ts | 369 ++++++++++++++++++ 17 files changed, 1121 insertions(+), 14 deletions(-) create mode 100644 App/pelagia-portal/app/(portal)/po/import/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/po/import/import-form.tsx create mode 100644 App/pelagia-portal/app/(portal)/po/import/page.tsx create mode 100644 App/pelagia-portal/app/api/po/import/route.ts create mode 100644 App/pelagia-portal/app/api/products/search/route.ts diff --git a/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts b/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts index 6353ddf..c6a427e 100644 --- a/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts +++ b/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts @@ -31,6 +31,10 @@ export async function approvepo({ return { error: "You cannot approve this PO." }; } + if (!po.vendorId) { + return { error: "A vendor must be assigned before approving this PO." }; + } + await db.purchaseOrder.update({ where: { id: poId }, data: { diff --git a/App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx b/App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx index 53dedcc..6b598bd 100644 --- a/App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx +++ b/App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx @@ -64,6 +64,15 @@ export default async function ApprovalDetailPage({ params }: Props) { + {!po.vendorId && ( +
+

Vendor required before approval

+

+ This PO has no vendor assigned. Use “Request Vendor ID” to route it for vendor selection, or assign a vendor via the Edit PO form. +

+
+ )} + { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + if (!hasPermission(session.user.role, "create_po") && session.user.role !== "ADMIN") { + return { error: "You do not have permission to import purchase orders." }; + } + + const total = input.lineItems.reduce( + (sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)), + 0 + ); + + const po = await db.purchaseOrder.create({ + data: { + poNumber: generatePoNumber(), + title: input.title, + status: "DRAFT", + totalAmount: total, + currency: "INR", + vesselId: input.vesselId, + accountId: input.accountId, + vendorId: input.vendorId ?? null, + piQuotationNo: input.piQuotationNo ?? null, + placeOfDelivery: input.placeOfDelivery ?? null, + tcDelivery: input.tcDelivery ?? null, + tcDispatch: input.tcDispatch ?? null, + tcInspection: input.tcInspection ?? null, + tcTransitInsurance: input.tcTransitInsurance ?? null, + tcPaymentTerms: input.tcPaymentTerms ?? null, + tcOthers: input.tcOthers ?? null, + submitterId: session.user.id, + lineItems: { + create: input.lineItems.map((item, idx) => ({ + description: item.description, + quantity: item.quantity, + unit: item.unit, + unitPrice: item.unitPrice, + totalPrice: item.quantity * item.unitPrice, + gstRate: item.gstRate ?? 0.18, + sortOrder: idx, + })), + }, + actions: { + create: { actionType: "CREATED", actorId: session.user.id }, + }, + }, + }); + + revalidatePath("/my-orders"); + revalidatePath("/dashboard"); + return { id: po.id }; +} diff --git a/App/pelagia-portal/app/(portal)/po/import/import-form.tsx b/App/pelagia-portal/app/(portal)/po/import/import-form.tsx new file mode 100644 index 0000000..a6ea0e7 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/import/import-form.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import type { Vessel, Account, Vendor } from "@prisma/client"; +import { importPo } from "./actions"; +import type { ParsedImport } from "@/app/api/po/import/route"; +import { formatCurrency } from "@/lib/utils"; + +const INPUT_CLS = + "w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; + +interface Props { + vessels: Vessel[]; + accounts: Account[]; + vendors: Vendor[]; +} + +type PreviewState = { + parsed: ParsedImport; + title: string; + vesselId: string; + accountId: string; + vendorId: string; +}; + +export function ImportForm({ vessels, accounts, vendors }: Props) { + const router = useRouter(); + const fileRef = useRef(null); + const [parsing, setParsing] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const [preview, setPreview] = useState(null); + + async function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setParsing(true); + setError(""); + setPreview(null); + + const fd = new FormData(); + fd.append("file", file); + + try { + const res = await fetch("/api/po/import", { method: "POST", body: fd }); + const data = await res.json(); + if (!res.ok || data.error) { + setError(data.error ?? "Failed to parse file"); + setParsing(false); + return; + } + + const parsed: ParsedImport = data.results[0]; + // Auto-match vendor by name (case-insensitive substring) + const matchedVendor = vendors.find( + (v) => v.isActive && parsed.vendorName && + v.name.toLowerCase().includes(parsed.vendorName.toLowerCase().slice(0, 10)) + ); + + setPreview({ + parsed, + title: parsed.vendorName + ? `${parsed.vendorName} — Import` + : "Imported Purchase Order", + vesselId: vessels[0]?.id ?? "", + accountId: accounts[0]?.id ?? "", + vendorId: matchedVendor?.id ?? "", + }); + } catch { + setError("Network error while parsing file"); + } finally { + setParsing(false); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!preview) return; + setSubmitting(true); + setError(""); + + const result = await importPo({ + title: preview.title, + vesselId: preview.vesselId, + accountId: preview.accountId, + vendorId: preview.vendorId || undefined, + piQuotationNo: preview.parsed.piQuotationNo || undefined, + placeOfDelivery: preview.parsed.placeOfDelivery || undefined, + tcDelivery: preview.parsed.tcDelivery || undefined, + tcDispatch: preview.parsed.tcDispatch || undefined, + tcInspection: preview.parsed.tcInspection || undefined, + tcTransitInsurance: preview.parsed.tcTransitInsurance || undefined, + tcPaymentTerms: preview.parsed.tcPaymentTerms || undefined, + tcOthers: preview.parsed.tcOthers || undefined, + lineItems: preview.parsed.lineItems, + }); + + if ("error" in result) { + setError(result.error); + setSubmitting(false); + } else { + router.push(`/po/${result.id}`); + } + } + + function reset() { + setPreview(null); + setError(""); + if (fileRef.current) fileRef.current.value = ""; + } + + if (!preview) { + return ( +
+
+
+ + + +
+

Upload Excel PO file

+

+ Supports the Pelagia PO Excel format (.xlsx). Each sheet is treated as one PO. +

+
+ + + + {error && ( +

{error}

+ )} +
+ ); + } + + const { parsed } = preview; + const total = parsed.lineItems.reduce( + (s, li) => s + li.quantity * li.unitPrice * (1 + (li.gstRate ?? 0.18)), 0 + ); + + return ( +
+ {/* Extracted data banner */} +
+ Parsed from file.{" "} + {parsed.vendorName && <>Vendor: {parsed.vendorName}. } + {parsed.piQuotationNo && <>Quotation: {parsed.piQuotationNo}. } + Review and fill in the fields below, then click “Create as Draft”. +
+ + {/* User-required fields */} +
+

Order Information

+
+ + setPreview({ ...preview, title: e.target.value })} + required + className={INPUT_CLS} + /> +
+
+
+ + +
+
+ + +
+
+
+ + + {parsed.vendorName && !preview.vendorId && ( +

+ Extracted vendor “{parsed.vendorName}” — no match found. Assign or add from Vendor Registry. +

+ )} +
+
+ + {/* Line items preview */} +
+

+ Line Items ({parsed.lineItems.length} items) +

+
+ + + + + + + + + + + + + {parsed.lineItems.map((li, i) => { + const taxable = li.quantity * li.unitPrice; + const lineTotal = taxable * (1 + (li.gstRate ?? 0.18)); + return ( + + + + + + + + + ); + })} + + + + + + + +
DescriptionQtyUnitUnit PriceGST%Total
{li.description}{li.quantity}{li.unit}{formatCurrency(li.unitPrice)}{Math.round((li.gstRate ?? 0.18) * 100)}%{formatCurrency(lineTotal)}
Grand Total{formatCurrency(total)}
+
+
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/po/import/page.tsx b/App/pelagia-portal/app/(portal)/po/import/page.tsx new file mode 100644 index 0000000..53d0597 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/import/page.tsx @@ -0,0 +1,34 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { redirect } from "next/navigation"; +import { ImportForm } from "./import-form"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Import Purchase Order" }; + +export default async function ImportPoPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const { role } = session.user; + if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard"); + + const [vessels, accounts, vendors] = await Promise.all([ + db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), + db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), + db.vendor.findMany({ orderBy: { name: "asc" } }), + ]); + + return ( +
+
+

Import Purchase Order

+

+ Upload a Pelagia-format Excel PO file. Line items and vendor details are extracted automatically. + You then select the vessel, account, and confirm before saving as a draft. +

+
+ +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/po/new/actions.ts b/App/pelagia-portal/app/(portal)/po/new/actions.ts index 15e4f1c..d176270 100644 --- a/App/pelagia-portal/app/(portal)/po/new/actions.ts +++ b/App/pelagia-portal/app/(portal)/po/new/actions.ts @@ -29,6 +29,7 @@ export async function createPo( size?: string; unitPrice: number; gstRate: number; + productId?: string; }> = []; let i = 0; while (formData.has(`lineItems[${i}].description`)) { @@ -39,6 +40,7 @@ export async function createPo( size: (formData.get(`lineItems[${i}].size`) as string) || undefined, unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)), gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18), + productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined, }); i++; } @@ -110,6 +112,7 @@ export async function createPo( totalPrice: item.quantity * item.unitPrice, gstRate: item.gstRate, sortOrder: idx, + productId: item.productId ?? null, })), }, actions: { diff --git a/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx b/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx index b64f890..9132181 100644 --- a/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx +++ b/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx @@ -41,6 +41,7 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) { data.set(`lineItems[${i}].size`, item.size ?? ""); data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice)); data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18)); + if (item.productId) data.set(`lineItems[${i}].productId`, item.productId); }); const result = await createPo(data); diff --git a/App/pelagia-portal/app/api/po/import/route.ts b/App/pelagia-portal/app/api/po/import/route.ts new file mode 100644 index 0000000..fc14e3d --- /dev/null +++ b/App/pelagia-portal/app/api/po/import/route.ts @@ -0,0 +1,156 @@ +import { auth } from "@/auth"; +import { NextRequest, NextResponse } from "next/server"; +import * as XLSX from "xlsx"; + +export type ParsedImportLine = { + description: string; + unit: string; + quantity: number; + unitPrice: number; + gstRate: number; +}; + +export type ParsedImport = { + poNumber: string; + piQuotationNo: string; + placeOfDelivery: string; + tcDelivery: string; + tcDispatch: string; + tcInspection: string; + tcTransitInsurance: string; + tcPaymentTerms: string; + tcOthers: string; + vendorName: string; + vendorAddress: string; + vendorContact: string; + lineItems: ParsedImportLine[]; +}; + +function cellStr(sheet: XLSX.WorkSheet, row: number, col: number): string { + const addr = XLSX.utils.encode_cell({ r: row, c: col }); + const cell = sheet[addr]; + if (!cell) return ""; + return String(cell.v ?? "").trim(); +} + +function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number { + const addr = XLSX.utils.encode_cell({ r: row, c: col }); + const cell = sheet[addr]; + if (!cell) return 0; + const v = parseFloat(String(cell.v)); + return isNaN(v) ? 0 : v; +} + +function parseSheet(sheet: XLSX.WorkSheet): ParsedImport { + // Row 4: PO Number at col 2 + const poNumber = cellStr(sheet, 4, 2); + + // Row 5: PI/Quotation No at col 2 + const piQuotationNo = cellStr(sheet, 5, 2); + + // Row 8: Place of Delivery at col 2 + const placeOfDelivery = cellStr(sheet, 8, 2); + + // Row 12: Vendor Name at col 2, Vendor Address at col 3 + const vendorName = cellStr(sheet, 12, 2); + const vendorAddress = cellStr(sheet, 12, 3); + + // Row 13: Contact at col 2 + const vendorContact = cellStr(sheet, 13, 2); + + // T&C rows 28-33 (col 1) + const tcDelivery = cellStr(sheet, 28, 1).replace(/^DELIVERY\s*:\s*/i, "").trim(); + const tcDispatch = cellStr(sheet, 29, 1).replace(/^DISPATCH INSTRUCTIONS:\s*/i, "").trim(); + const tcInspection = cellStr(sheet, 30, 1).replace(/^INSPECTION\s*:\s*/i, "").trim(); + const tcTransitInsurance = cellStr(sheet, 31, 1).replace(/^TRANSIT INSURANCE:\s*/i, "").trim(); + const tcPaymentTerms = cellStr(sheet, 32, 1).replace(/^PAYMENT TERMS:\s*/i, "").trim(); + const tcOthers = cellStr(sheet, 33, 1).trim(); + + // Line items start at row 15 (index 15) + const lineItems: ParsedImportLine[] = []; + for (let r = 15; r <= 100; r++) { + const sn = cellStr(sheet, r, 0); + const desc = cellStr(sheet, r, 1); + if (!desc && !sn) continue; + if (!desc) continue; + // Stop at summary rows + if (desc.toLowerCase().includes("total") || desc.toLowerCase().includes("grand")) break; + + const unitRaw = cellStr(sheet, r, 3); + const qty = cellNum(sheet, r, 4); + const unitPrice = cellNum(sheet, r, 5); + const gstRaw = cellNum(sheet, r, 7); + const gstRate = gstRaw > 1 ? gstRaw / 100 : gstRaw; + + lineItems.push({ + description: desc, + unit: unitRaw || "pc", + quantity: qty || 1, + unitPrice, + gstRate: gstRate || 0.18, + }); + } + + return { + poNumber, + piQuotationNo, + placeOfDelivery, + tcDelivery, + tcDispatch, + tcInspection, + tcTransitInsurance, + tcPaymentTerms, + tcOthers, + vendorName, + vendorAddress, + vendorContact, + lineItems, + }; +} + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { role } = session.user; + if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); + } + + const file = formData.get("file") as File | null; + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); + + const buffer = Buffer.from(await file.arrayBuffer()); + let workbook: XLSX.WorkBook; + try { + workbook = XLSX.read(buffer, { type: "buffer" }); + } catch { + return NextResponse.json({ error: "Could not parse Excel file. Ensure it is a valid .xlsx file." }, { status: 400 }); + } + + const results: ParsedImport[] = []; + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + try { + const parsed = parseSheet(sheet); + if (parsed.lineItems.length > 0) { + results.push(parsed); + } + } catch { + // skip unparseable sheets + } + } + + if (results.length === 0) { + return NextResponse.json({ error: "No valid purchase order data found in the file." }, { status: 400 }); + } + + return NextResponse.json({ results }); +} diff --git a/App/pelagia-portal/app/api/products/search/route.ts b/App/pelagia-portal/app/api/products/search/route.ts new file mode 100644 index 0000000..866ade4 --- /dev/null +++ b/App/pelagia-portal/app/api/products/search/route.ts @@ -0,0 +1,32 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const q = req.nextUrl.searchParams.get("q")?.trim() ?? ""; + if (q.length < 2) return NextResponse.json([]); + + const products = await db.product.findMany({ + where: { + isActive: true, + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { code: { contains: q, mode: "insensitive" } }, + { description: { contains: q, mode: "insensitive" } }, + ], + }, + select: { id: true, code: true, name: true, description: true, lastPrice: true }, + take: 10, + orderBy: { name: "asc" }, + }); + + return NextResponse.json( + products.map((p) => ({ + ...p, + lastPrice: p.lastPrice != null ? Number(p.lastPrice) : null, + })) + ); +} diff --git a/App/pelagia-portal/components/layout/sidebar.tsx b/App/pelagia-portal/components/layout/sidebar.tsx index 6f684fc..daf6494 100644 --- a/App/pelagia-portal/components/layout/sidebar.tsx +++ b/App/pelagia-portal/components/layout/sidebar.tsx @@ -16,6 +16,7 @@ import { Store, Anchor, Package, + Upload, } from "lucide-react"; import type { Role } from "@prisma/client"; @@ -28,11 +29,13 @@ interface NavItem { const NAV_ITEMS: NavItem[] = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, - { href: "/my-orders", label: "My Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, + { href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] }, + { href: "/my-orders", label: "My Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] }, + { href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] }, { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] }, + { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS"] }, ]; const ADMIN_ITEMS: NavItem[] = [ diff --git a/App/pelagia-portal/components/po/po-line-items-editor.tsx b/App/pelagia-portal/components/po/po-line-items-editor.tsx index ebddbbe..b00a469 100644 --- a/App/pelagia-portal/components/po/po-line-items-editor.tsx +++ b/App/pelagia-portal/components/po/po-line-items-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Plus, Trash2 } from "lucide-react"; import { formatCurrency } from "@/lib/utils"; import type { LineItemInput } from "@/lib/validations/po"; @@ -21,8 +21,17 @@ const UOM_OPTIONS = [ { value: "hr", label: "hr — Hour" }, { value: "day", label: "day — Day" }, { value: "lump", label: "lump — Lump Sum" }, + { value: "Ltr", label: "Ltr — Litre (alt)" }, ]; +type ProductHit = { + id: string; + code: string; + name: string; + description: string | null; + lastPrice: number | null; +}; + interface Props { items: LineItemInput[]; onChange?: (items: LineItemInput[]) => void; @@ -37,6 +46,7 @@ type EditRow = { size: string; unitPrice: string; gstRate: string; + productId?: string; }; function toEditRow(item: LineItemInput): EditRow { @@ -47,6 +57,7 @@ function toEditRow(item: LineItemInput): EditRow { size: item.size ?? "", unitPrice: item.unitPrice ? String(item.unitPrice) : "", gstRate: String(item.gstRate ?? 0.18), + productId: item.productId, }; } @@ -58,6 +69,7 @@ function toLineItem(row: EditRow): LineItemInput { size: row.size || undefined, unitPrice: parseFloat(row.unitPrice) || 0, gstRate: parseFloat(row.gstRate) || 0.18, + productId: row.productId || undefined, }; } @@ -67,6 +79,94 @@ function calcTotals(items: LineItemInput[]) { return { taxable, gst, grand: taxable + gst }; } +function DescriptionCell({ + value, + productId, + onChange, +}: { + value: string; + productId?: string; + onChange: (desc: string, pid?: string, price?: number) => void; +}) { + const [hits, setHits] = useState([]); + const [open, setOpen] = useState(false); + const timerRef = useRef | null>(null); + const wrapRef = useRef(null); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function handleInput(v: string) { + onChange(v, undefined); + if (timerRef.current) clearTimeout(timerRef.current); + if (v.length < 2) { setHits([]); setOpen(false); return; } + timerRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/products/search?q=${encodeURIComponent(v)}`); + const data: ProductHit[] = await res.json(); + setHits(data); + setOpen(data.length > 0); + } catch { + setHits([]); + } + }, 250); + } + + function select(hit: ProductHit) { + onChange(hit.name, hit.id, hit.lastPrice ?? undefined); + setOpen(false); + setHits([]); + } + + return ( +
+ handleInput(e.target.value)} + onFocus={() => { if (hits.length > 0) setOpen(true); }} + className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none" + placeholder="Item description" + /> + {productId && ( + + ✓ linked + + )} + {open && ( +
    + {hits.map((hit) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + export function LineItemsEditor({ items, onChange, readOnly = false, originalItems }: Props) { const [rows, setRows] = useState(() => items.map(toEditRow)); @@ -79,6 +179,20 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte updateRows(rows.map((row, i) => (i === index ? { ...row, [field]: value } : row))); } + function updateDescription(index: number, desc: string, productId?: string, price?: number) { + updateRows( + rows.map((row, i) => { + if (i !== index) return row; + return { + ...row, + description: desc, + productId: productId ?? undefined, + unitPrice: price != null ? String(price) : row.unitPrice, + }; + }) + ); + } + function add() { updateRows([...rows, { description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]); } @@ -188,11 +302,10 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte return ( - update(i, "description", e.target.value)} - className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none" - placeholder="Item description" + productId={row.productId} + onChange={(desc, pid, price) => updateDescription(i, desc, pid, price)} /> diff --git a/App/pelagia-portal/lib/permissions.ts b/App/pelagia-portal/lib/permissions.ts index 11e91c7..a0af759 100644 --- a/App/pelagia-portal/lib/permissions.ts +++ b/App/pelagia-portal/lib/permissions.ts @@ -22,8 +22,12 @@ export type Permission = const ROLE_PERMISSIONS: Record = { TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], - ACCOUNTS: ["view_all_pos", "process_payment"], + ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors"], MANAGER: [ + "create_po", + "submit_po", + "edit_own_draft_po", + "view_own_pos", "view_all_pos", "approve_po", "reject_po", @@ -31,6 +35,7 @@ const ROLE_PERMISSIONS: Record = { "request_vendor_id", "view_analytics", "export_reports", + "manage_vendors", ], SUPERUSER: [ "create_po", diff --git a/App/pelagia-portal/lib/po-state-machine.ts b/App/pelagia-portal/lib/po-state-machine.ts index b3270d6..1ff691a 100644 --- a/App/pelagia-portal/lib/po-state-machine.ts +++ b/App/pelagia-portal/lib/po-state-machine.ts @@ -31,7 +31,7 @@ const TRANSITIONS: Partial> = { DRAFT: { submit: { to: "SUBMITTED", - allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], + allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"], requiresNote: false, sideEffects: ["EMAIL_MANAGER"], }, @@ -74,7 +74,7 @@ const TRANSITIONS: Partial> = { VENDOR_ID_PENDING: { provide_vendor_id: { to: "MGR_REVIEW", - allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], + allowedRoles: ["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER"], requiresNote: false, sideEffects: ["EMAIL_MANAGER"], }, @@ -82,7 +82,7 @@ const TRANSITIONS: Partial> = { EDITS_REQUESTED: { submit: { to: "SUBMITTED", - allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], + allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"], requiresNote: false, sideEffects: ["EMAIL_MANAGER"], }, diff --git a/App/pelagia-portal/lib/validations/po.ts b/App/pelagia-portal/lib/validations/po.ts index ab4d290..82e78bf 100644 --- a/App/pelagia-portal/lib/validations/po.ts +++ b/App/pelagia-portal/lib/validations/po.ts @@ -7,6 +7,7 @@ export const lineItemSchema = z.object({ size: z.string().optional(), unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"), gstRate: z.coerce.number().min(0).max(1).default(0.18), + productId: z.string().optional(), }); export const TC_FIXED_LINE = diff --git a/App/pelagia-portal/prisma/seed.ts b/App/pelagia-portal/prisma/seed.ts index 866e524..31d0cf9 100644 --- a/App/pelagia-portal/prisma/seed.ts +++ b/App/pelagia-portal/prisma/seed.ts @@ -116,6 +116,8 @@ async function main() { vendorId: "VND-0001", contactName: "Tony Nguyen", contactEmail: "tnguyen@marinepartsinternational.com", + address: "Plot 12, MIDC Industrial Area, Turbhe, Navi Mumbai 400705", + gstin: "27AABCM1234A1Z5", isVerified: true, }, }); @@ -128,6 +130,7 @@ async function main() { vendorId: "VND-0002", contactName: "Sarah Kim", contactEmail: "sarah@globalcrewsupplies.com", + address: "14B, Harbour Street, Mazgaon, Mumbai 400010", isVerified: true, }, }); @@ -140,6 +143,137 @@ async function main() { vendorId: "VND-0003", contactName: "Marco Rossi", contactEmail: "marco@atlaschandlers.com", + address: "Unit 7, Sassoon Dock, Colaba, Mumbai 400005", + isVerified: false, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0004" }, + update: {}, + create: { + name: "Apar Industries Ltd", + vendorId: "VND-0004", + contactName: "Nikhil Mumbaikar", + contactMobile: "7208055636", + contactEmail: "nikhil.mumbaikar@apar.com", + address: "18, TTC MIDC Industrial Area, Thane Belapur Road, Opp Rabale Railway Stn, Navi Mumbai 400701", + gstin: "27AAACG1840M1ZL", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0005" }, + update: {}, + create: { + name: "Neptune Marine Stores", + vendorId: "VND-0005", + contactName: "Ravi Sharma", + contactMobile: "9821234567", + contactEmail: "ravi@neptunemarine.in", + address: "B-204, Jogeshwari Industrial Estate, Mumbai 400060", + gstin: "27AADCN5678B1ZK", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0006" }, + update: {}, + create: { + name: "Seaview Hydraulics Pvt Ltd", + vendorId: "VND-0006", + contactName: "Anand Pillai", + contactMobile: "9867543210", + contactEmail: "anand@seaviewhydraulics.com", + address: "Plot 45, Phase II, Ambad MIDC, Nashik 422010", + gstin: "27AABCS9876C1ZM", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0007" }, + update: {}, + create: { + name: "Pacific Safety Equipment", + vendorId: "VND-0007", + contactName: "Priya Nair", + contactEmail: "priya@pacificsafety.com", + address: "22, Linking Road, Bandra West, Mumbai 400050", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0008" }, + update: {}, + create: { + name: "Mumbai Ship Stores", + vendorId: "VND-0008", + contactName: "Deepak Mehta", + contactMobile: "9876543210", + contactEmail: "deepak@mumbaishipstores.com", + address: "78, Ballard Estate, Fort, Mumbai 400001", + gstin: "27AAACM4567D1ZJ", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0009" }, + update: {}, + create: { + name: "Bharat Navigation Systems", + vendorId: "VND-0009", + contactName: "Suresh Kumar", + contactEmail: "suresh@bharatnav.in", + address: "67, M.G. Road, Pune 411001", + isVerified: false, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0010" }, + update: {}, + create: { + name: "Coastal Rope & Rigging", + vendorId: "VND-0010", + contactName: "James D'Souza", + contactMobile: "9823456789", + contactEmail: "james@coastalrope.com", + address: "Sector 12, Kandivali East, Mumbai 400101", + gstin: "27AABCC2345E1ZP", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0011" }, + update: {}, + create: { + name: "Indotech Filters & Fluids", + vendorId: "VND-0011", + contactName: "Meenakshi Srinivasan", + contactEmail: "msrinivasan@indotech.co.in", + address: "E-12, Peenya Industrial Area Phase 1, Bengaluru 560058", + gstin: "29AABCI5678F1ZL", + isVerified: true, + }, + }); + + await db.vendor.upsert({ + where: { vendorId: "VND-0012" }, + update: {}, + create: { + name: "Eastern Electro Marine", + vendorId: "VND-0012", + contactName: "Rahul Das", + contactMobile: "9432167890", + contactEmail: "rahul.das@easternelectro.com", + address: "12A, Strand Road, Kolkata 700001", + gstin: "19AABCE6789G1ZK", isVerified: false, }, }); @@ -152,6 +286,7 @@ async function main() { code: "PART-TURBO-SEAL", name: "Turbocharger Seal Kit", description: "Replacement seal kit for main engine turbocharger", + lastPrice: 1200, }, }); @@ -162,6 +297,7 @@ async function main() { code: "PART-FP-PUMP", name: "High-Pressure Fuel Pump", description: "Main engine high-pressure fuel pump assembly", + lastPrice: 4800, }, }); @@ -172,6 +308,7 @@ async function main() { code: "SAFE-LIFEJKT", name: "Life Jacket (SOLAS)", description: "SOLAS-approved adult life jacket", + lastPrice: 120, }, }); @@ -182,6 +319,238 @@ async function main() { code: "SAFE-EXTG-9KG", name: "Fire Extinguisher 9kg", description: "Dry powder fire extinguisher, 9kg", + lastPrice: 200, + }, + }); + + await db.product.upsert({ + where: { code: "LUBE-EP80W90" }, + update: {}, + create: { + code: "LUBE-EP80W90", + name: "Gear Oil EP 80W90", + description: "Eni EP 80W90 gear oil for marine gearboxes", + lastPrice: 182, + }, + }); + + await db.product.upsert({ + where: { code: "LUBE-HYD46" }, + update: {}, + create: { + code: "LUBE-HYD46", + name: "Hydraulic Oil ISO 46", + description: "Anti-wear hydraulic oil ISO VG 46 for deck machinery", + lastPrice: 155, + }, + }); + + await db.product.upsert({ + where: { code: "LUBE-MEO30" }, + update: {}, + create: { + code: "LUBE-MEO30", + name: "Main Engine Oil SAE 30", + description: "Trunk piston engine oil SAE 30 for 4-stroke engines", + lastPrice: 210, + }, + }); + + await db.product.upsert({ + where: { code: "FILT-OIL-ME" }, + update: {}, + create: { + code: "FILT-OIL-ME", + name: "Main Engine Lube Oil Filter", + description: "Spin-on lube oil filter for main engine", + lastPrice: 850, + }, + }); + + await db.product.upsert({ + where: { code: "FILT-FUEL-ME" }, + update: {}, + create: { + code: "FILT-FUEL-ME", + name: "Main Engine Fuel Filter", + description: "Duplex fuel oil filter element for main engine", + lastPrice: 1100, + }, + }); + + await db.product.upsert({ + where: { code: "FILT-AIR-ME" }, + update: {}, + create: { + code: "FILT-AIR-ME", + name: "Air Filter Element", + description: "Air cleaner filter element for main engine air intake", + lastPrice: 650, + }, + }); + + await db.product.upsert({ + where: { code: "PART-ORING-ASST" }, + update: {}, + create: { + code: "PART-ORING-ASST", + name: "O-Ring Assortment Pack", + description: "Mixed O-ring kit with Nitrile and Viton rings, 500 pcs", + lastPrice: 250, + }, + }); + + await db.product.upsert({ + where: { code: "PART-GASKET-SET" }, + update: {}, + create: { + code: "PART-GASKET-SET", + name: "Exhaust Gasket Set", + description: "Full set of exhaust manifold gaskets for main engine", + lastPrice: 3200, + }, + }); + + await db.product.upsert({ + where: { code: "ELEC-LAMP-LED" }, + update: {}, + create: { + code: "ELEC-LAMP-LED", + name: "LED Navigation Lamp", + description: "SOLAS-compliant LED navigation light, white masthead", + lastPrice: 4500, + }, + }); + + await db.product.upsert({ + where: { code: "ELEC-BATT-12V" }, + update: {}, + create: { + code: "ELEC-BATT-12V", + name: "Starting Battery 12V 100Ah", + description: "Sealed lead-acid starting battery for emergency equipment", + lastPrice: 6500, + }, + }); + + await db.product.upsert({ + where: { code: "ELEC-CABLE-3C" }, + update: {}, + create: { + code: "ELEC-CABLE-3C", + name: "Marine Cable 3-Core 2.5mm²", + description: "Tinned copper 3-core marine electrical cable, per metre", + lastPrice: 85, + }, + }); + + await db.product.upsert({ + where: { code: "ROPE-MOORING-40" }, + update: {}, + create: { + code: "ROPE-MOORING-40", + name: "Mooring Rope 40mm × 200m", + description: "3-strand polypropylene mooring rope, 40mm dia, 200m coil", + lastPrice: 18500, + }, + }); + + await db.product.upsert({ + where: { code: "ROPE-PILOT-18" }, + update: {}, + create: { + code: "ROPE-PILOT-18", + name: "Pilot Ladder Rope 18mm", + description: "Manilla pilot ladder rope, 18mm, certified per metre", + lastPrice: 320, + }, + }); + + await db.product.upsert({ + where: { code: "SAFE-IMMSUIT" }, + update: {}, + create: { + code: "SAFE-IMMSUIT", + name: "Immersion Suit (SOLAS)", + description: "SOLAS-approved adult immersion suit, insulated", + lastPrice: 5500, + }, + }); + + await db.product.upsert({ + where: { code: "SAFE-EPIRB" }, + update: {}, + create: { + code: "SAFE-EPIRB", + name: "EPIRB (406 MHz)", + description: "Category I float-free EPIRB, 406 MHz COSPAS-SARSAT", + lastPrice: 45000, + }, + }); + + await db.product.upsert({ + where: { code: "SAFE-FLARE-HAND" }, + update: {}, + create: { + code: "SAFE-FLARE-HAND", + name: "Hand Flare (SOLAS)", + description: "Red hand flare for distress signalling, pack of 6", + lastPrice: 1800, + }, + }); + + await db.product.upsert({ + where: { code: "PAINT-ANTIFOUL" }, + update: {}, + create: { + code: "PAINT-ANTIFOUL", + name: "Antifouling Paint 20L", + description: "Self-polishing antifouling paint for hull below waterline", + lastPrice: 8200, + }, + }); + + await db.product.upsert({ + where: { code: "PAINT-PRIMER" }, + update: {}, + create: { + code: "PAINT-PRIMER", + name: "Epoxy Primer 5L", + description: "Two-component epoxy primer for steel and aluminium surfaces", + lastPrice: 3400, + }, + }); + + await db.product.upsert({ + where: { code: "TOOL-GRINDER-4" }, + update: {}, + create: { + code: "TOOL-GRINDER-4", + name: "Angle Grinder 4-inch", + description: "Heavy-duty 4-inch angle grinder, 850W, with guard", + lastPrice: 2800, + }, + }); + + await db.product.upsert({ + where: { code: "CHART-INT-1" }, + update: {}, + create: { + code: "CHART-INT-1", + name: "INT Chart Folio Update", + description: "Annual update pack for navigational charts, Indian Ocean folio", + lastPrice: 950, + }, + }); + + await db.product.upsert({ + where: { code: "CHEM-BOWTREATER" }, + update: {}, + create: { + code: "CHEM-BOWTREATER", + name: "Boiler Water Treatment Chemical 25L", + description: "Liquid boiler water treatment and scale inhibitor", + lastPrice: 3600, }, });