feat: manager PO creation, vendor management, import from Excel, item fuzzy search

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 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-09 18:52:51 +05:30
parent 17586e6ea1
commit 43f0861591
17 changed files with 1121 additions and 14 deletions

View file

@ -31,6 +31,10 @@ export async function approvepo({
return { error: "You cannot approve this PO." }; 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({ await db.purchaseOrder.update({
where: { id: poId }, where: { id: poId },
data: { data: {

View file

@ -64,6 +64,15 @@ export default async function ApprovalDetailPage({ params }: Props) {
</div> </div>
</div> </div>
{!po.vendorId && (
<div className="mb-4 rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
<p className="text-sm font-medium text-danger-700">Vendor required before approval</p>
<p className="text-sm text-danger-600 mt-0.5">
This PO has no vendor assigned. Use &ldquo;Request Vendor ID&rdquo; to route it for vendor selection, or assign a vendor via the Edit PO form.
</p>
</div>
)}
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly /> <PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
<ManagerEditPoForm <ManagerEditPoForm

View file

@ -13,7 +13,7 @@ export default async function MyOrdersPage() {
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
const { role, id: userId } = session.user; const { role, id: userId } = session.user;
if (!["TECHNICAL", "MANNING", "SUPERUSER"].includes(role)) redirect("/dashboard"); if (!["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"].includes(role)) redirect("/dashboard");
const pos = await db.purchaseOrder.findMany({ const pos = await db.purchaseOrder.findMany({
where: { submitterId: userId }, where: { submitterId: userId },

View file

@ -45,8 +45,10 @@ export default async function PoDetailPage({ params }: Props) {
const canProvideVendorId = const canProvideVendorId =
po.status === "VENDOR_ID_PENDING" && po.status === "VENDOR_ID_PENDING" &&
["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"].includes(session.user.role) && (
(po.submitterId === session.user.id || ["MANAGER", "SUPERUSER"].includes(session.user.role)); (["TECHNICAL", "MANNING"].includes(session.user.role) && po.submitterId === session.user.id) ||
["ACCOUNTS", "MANAGER", "SUPERUSER"].includes(session.user.role)
);
const vendors = canProvideVendorId const vendors = canProvideVendorId
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }) ? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })

View file

@ -0,0 +1,79 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { generatePoNumber } from "@/lib/utils";
import { revalidatePath } from "next/cache";
import type { ParsedImportLine } from "@/app/api/po/import/route";
export type ImportPoInput = {
title: string;
vesselId: string;
accountId: string;
vendorId?: string;
piQuotationNo?: string;
placeOfDelivery?: string;
tcDelivery?: string;
tcDispatch?: string;
tcInspection?: string;
tcTransitInsurance?: string;
tcPaymentTerms?: string;
tcOthers?: string;
lineItems: ParsedImportLine[];
};
export async function importPo(
input: ImportPoInput
): Promise<{ id: string } | { error: string }> {
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 };
}

View file

@ -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<HTMLInputElement>(null);
const [parsing, setParsing] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [preview, setPreview] = useState<PreviewState | null>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
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<HTMLFormElement>) {
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 (
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center">
<div className="mb-4">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50">
<svg className="h-7 w-7 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="text-sm font-medium text-neutral-900 mb-1">Upload Excel PO file</p>
<p className="text-xs text-neutral-500">
Supports the Pelagia PO Excel format (.xlsx). Each sheet is treated as one PO.
</p>
</div>
<label className="cursor-pointer">
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFile}
className="sr-only"
disabled={parsing}
/>
<span className={`inline-flex items-center gap-2 rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors ${parsing ? "opacity-60 cursor-not-allowed" : ""}`}>
{parsing ? "Parsing…" : "Choose Excel File"}
</span>
</label>
{error && (
<p className="mt-4 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
</div>
);
}
const { parsed } = preview;
const total = parsed.lineItems.reduce(
(s, li) => s + li.quantity * li.unitPrice * (1 + (li.gstRate ?? 0.18)), 0
);
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Extracted data banner */}
<div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3 text-sm text-primary-800">
<span className="font-semibold">Parsed from file.</span>{" "}
{parsed.vendorName && <>Vendor: <strong>{parsed.vendorName}</strong>. </>}
{parsed.piQuotationNo && <>Quotation: <strong>{parsed.piQuotationNo}</strong>. </>}
Review and fill in the fields below, then click &ldquo;Create as Draft&rdquo;.
</div>
{/* User-required fields */}
<section className="rounded-lg border border-neutral-200 bg-white p-6 space-y-4">
<h2 className="text-base font-semibold text-neutral-900">Order Information</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Title <span className="text-danger">*</span>
</label>
<input
value={preview.title}
onChange={(e) => setPreview({ ...preview, title: e.target.value })}
required
className={INPUT_CLS}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Vessel <span className="text-danger">*</span>
</label>
<select
value={preview.vesselId}
onChange={(e) => setPreview({ ...preview, vesselId: e.target.value })}
required
className={INPUT_CLS}
>
<option value="">Select vessel</option>
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Account / Cost Centre <span className="text-danger">*</span>
</label>
<select
value={preview.accountId}
onChange={(e) => setPreview({ ...preview, accountId: e.target.value })}
required
className={INPUT_CLS}
>
<option value="">Select account</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Vendor</label>
<select
value={preview.vendorId}
onChange={(e) => setPreview({ ...preview, vendorId: e.target.value })}
className={INPUT_CLS}
>
<option value="">No vendor / add later</option>
{vendors.filter((v) => v.isActive).map((v) => (
<option key={v.id} value={v.id}>
{v.name}{v.vendorId ? ` (${v.vendorId})` : " (unverified)"}
</option>
))}
</select>
{parsed.vendorName && !preview.vendorId && (
<p className="mt-1 text-xs text-warning-700">
Extracted vendor &ldquo;{parsed.vendorName}&rdquo; no match found. Assign or add from Vendor Registry.
</p>
)}
</div>
</section>
{/* Line items preview */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">
Line Items <span className="ml-2 text-sm font-normal text-neutral-500">({parsed.lineItems.length} items)</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Description</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Qty</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-3">Unit</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Unit Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">GST%</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{parsed.lineItems.map((li, i) => {
const taxable = li.quantity * li.unitPrice;
const lineTotal = taxable * (1 + (li.gstRate ?? 0.18));
return (
<tr key={i}>
<td className="py-2 pr-4 text-neutral-900">{li.description}</td>
<td className="py-2 pl-4 text-right">{li.quantity}</td>
<td className="py-2 pl-3 text-neutral-500">{li.unit}</td>
<td className="py-2 pl-4 text-right">{formatCurrency(li.unitPrice)}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{Math.round((li.gstRate ?? 0.18) * 100)}%</td>
<td className="py-2 pl-4 text-right font-medium">{formatCurrency(lineTotal)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-neutral-200">
<td colSpan={5} className="pt-3 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td className="pt-3 pl-4 text-right text-sm font-semibold text-neutral-900">{formatCurrency(total)}</td>
</tr>
</tfoot>
</table>
</div>
</section>
{error && (
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={reset}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Upload Different File
</button>
<button
type="submit"
disabled={submitting || !preview.vesselId || !preview.accountId}
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
>
{submitting ? "Creating…" : "Create as Draft"}
</button>
</div>
</form>
);
}

View file

@ -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 (
<div className="max-w-3xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Import Purchase Order</h1>
<p className="mt-1 text-sm text-neutral-500">
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.
</p>
</div>
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} />
</div>
);
}

View file

@ -29,6 +29,7 @@ export async function createPo(
size?: string; size?: string;
unitPrice: number; unitPrice: number;
gstRate: number; gstRate: number;
productId?: string;
}> = []; }> = [];
let i = 0; let i = 0;
while (formData.has(`lineItems[${i}].description`)) { while (formData.has(`lineItems[${i}].description`)) {
@ -39,6 +40,7 @@ export async function createPo(
size: (formData.get(`lineItems[${i}].size`) as string) || undefined, size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)), unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18), gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined,
}); });
i++; i++;
} }
@ -110,6 +112,7 @@ export async function createPo(
totalPrice: item.quantity * item.unitPrice, totalPrice: item.quantity * item.unitPrice,
gstRate: item.gstRate, gstRate: item.gstRate,
sortOrder: idx, sortOrder: idx,
productId: item.productId ?? null,
})), })),
}, },
actions: { actions: {

View file

@ -41,6 +41,7 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
data.set(`lineItems[${i}].size`, item.size ?? ""); data.set(`lineItems[${i}].size`, item.size ?? "");
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice)); data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18)); 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); const result = await createPo(data);

View file

@ -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 });
}

View file

@ -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,
}))
);
}

View file

@ -16,6 +16,7 @@ import {
Store, Store,
Anchor, Anchor,
Package, Package,
Upload,
} from "lucide-react"; } from "lucide-react";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -28,11 +29,13 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, 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", "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: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] }, { 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[] = [ const ADMIN_ITEMS: NavItem[] = [

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { formatCurrency } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
@ -21,8 +21,17 @@ const UOM_OPTIONS = [
{ value: "hr", label: "hr — Hour" }, { value: "hr", label: "hr — Hour" },
{ value: "day", label: "day — Day" }, { value: "day", label: "day — Day" },
{ value: "lump", label: "lump — Lump Sum" }, { 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 { interface Props {
items: LineItemInput[]; items: LineItemInput[];
onChange?: (items: LineItemInput[]) => void; onChange?: (items: LineItemInput[]) => void;
@ -37,6 +46,7 @@ type EditRow = {
size: string; size: string;
unitPrice: string; unitPrice: string;
gstRate: string; gstRate: string;
productId?: string;
}; };
function toEditRow(item: LineItemInput): EditRow { function toEditRow(item: LineItemInput): EditRow {
@ -47,6 +57,7 @@ function toEditRow(item: LineItemInput): EditRow {
size: item.size ?? "", size: item.size ?? "",
unitPrice: item.unitPrice ? String(item.unitPrice) : "", unitPrice: item.unitPrice ? String(item.unitPrice) : "",
gstRate: String(item.gstRate ?? 0.18), gstRate: String(item.gstRate ?? 0.18),
productId: item.productId,
}; };
} }
@ -58,6 +69,7 @@ function toLineItem(row: EditRow): LineItemInput {
size: row.size || undefined, size: row.size || undefined,
unitPrice: parseFloat(row.unitPrice) || 0, unitPrice: parseFloat(row.unitPrice) || 0,
gstRate: parseFloat(row.gstRate) || 0.18, gstRate: parseFloat(row.gstRate) || 0.18,
productId: row.productId || undefined,
}; };
} }
@ -67,6 +79,94 @@ function calcTotals(items: LineItemInput[]) {
return { taxable, gst, grand: taxable + gst }; 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<ProductHit[]>([]);
const [open, setOpen] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapRef = useRef<HTMLDivElement>(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 (
<div ref={wrapRef} className="relative w-full">
<input
value={value}
onChange={(e) => 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 && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-primary-500 font-mono pointer-events-none select-none">
linked
</span>
)}
{open && (
<ul className="absolute z-50 left-0 right-0 top-full mt-0.5 max-h-52 overflow-y-auto rounded-lg border border-neutral-200 bg-white shadow-lg text-sm">
{hits.map((hit) => (
<li key={hit.id}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); select(hit); }}
className="w-full text-left px-3 py-2 hover:bg-primary-50 flex items-start gap-2"
>
<span className="font-mono text-xs text-neutral-400 shrink-0 mt-0.5 w-28 truncate">{hit.code}</span>
<span className="flex-1">
<span className="font-medium text-neutral-900">{hit.name}</span>
{hit.description && (
<span className="block text-xs text-neutral-500 truncate">{hit.description}</span>
)}
</span>
{hit.lastPrice != null && (
<span className="shrink-0 text-xs text-neutral-500">{formatCurrency(hit.lastPrice)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
);
}
export function LineItemsEditor({ items, onChange, readOnly = false, originalItems }: Props) { export function LineItemsEditor({ items, onChange, readOnly = false, originalItems }: Props) {
const [rows, setRows] = useState<EditRow[]>(() => items.map(toEditRow)); const [rows, setRows] = useState<EditRow[]>(() => 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))); 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() { function add() {
updateRows([...rows, { description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]); 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 ( return (
<tr key={i}> <tr key={i}>
<td className="py-2 pr-4"> <td className="py-2 pr-4">
<input <DescriptionCell
value={row.description} value={row.description}
onChange={(e) => update(i, "description", e.target.value)} productId={row.productId}
className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none" onChange={(desc, pid, price) => updateDescription(i, desc, pid, price)}
placeholder="Item description"
/> />
</td> </td>
<td className="py-2 pl-4"> <td className="py-2 pl-4">

View file

@ -22,8 +22,12 @@ export type Permission =
const ROLE_PERMISSIONS: Record<Role, Permission[]> = { const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], 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"], 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: [ MANAGER: [
"create_po",
"submit_po",
"edit_own_draft_po",
"view_own_pos",
"view_all_pos", "view_all_pos",
"approve_po", "approve_po",
"reject_po", "reject_po",
@ -31,6 +35,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"request_vendor_id", "request_vendor_id",
"view_analytics", "view_analytics",
"export_reports", "export_reports",
"manage_vendors",
], ],
SUPERUSER: [ SUPERUSER: [
"create_po", "create_po",

View file

@ -31,7 +31,7 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
DRAFT: { DRAFT: {
submit: { submit: {
to: "SUBMITTED", to: "SUBMITTED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_MANAGER"], sideEffects: ["EMAIL_MANAGER"],
}, },
@ -74,7 +74,7 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
VENDOR_ID_PENDING: { VENDOR_ID_PENDING: {
provide_vendor_id: { provide_vendor_id: {
to: "MGR_REVIEW", to: "MGR_REVIEW",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_MANAGER"], sideEffects: ["EMAIL_MANAGER"],
}, },
@ -82,7 +82,7 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
EDITS_REQUESTED: { EDITS_REQUESTED: {
submit: { submit: {
to: "SUBMITTED", to: "SUBMITTED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_MANAGER"], sideEffects: ["EMAIL_MANAGER"],
}, },

View file

@ -7,6 +7,7 @@ export const lineItemSchema = z.object({
size: z.string().optional(), size: z.string().optional(),
unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"), unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"),
gstRate: z.coerce.number().min(0).max(1).default(0.18), gstRate: z.coerce.number().min(0).max(1).default(0.18),
productId: z.string().optional(),
}); });
export const TC_FIXED_LINE = export const TC_FIXED_LINE =

View file

@ -116,6 +116,8 @@ async function main() {
vendorId: "VND-0001", vendorId: "VND-0001",
contactName: "Tony Nguyen", contactName: "Tony Nguyen",
contactEmail: "tnguyen@marinepartsinternational.com", contactEmail: "tnguyen@marinepartsinternational.com",
address: "Plot 12, MIDC Industrial Area, Turbhe, Navi Mumbai 400705",
gstin: "27AABCM1234A1Z5",
isVerified: true, isVerified: true,
}, },
}); });
@ -128,6 +130,7 @@ async function main() {
vendorId: "VND-0002", vendorId: "VND-0002",
contactName: "Sarah Kim", contactName: "Sarah Kim",
contactEmail: "sarah@globalcrewsupplies.com", contactEmail: "sarah@globalcrewsupplies.com",
address: "14B, Harbour Street, Mazgaon, Mumbai 400010",
isVerified: true, isVerified: true,
}, },
}); });
@ -140,6 +143,137 @@ async function main() {
vendorId: "VND-0003", vendorId: "VND-0003",
contactName: "Marco Rossi", contactName: "Marco Rossi",
contactEmail: "marco@atlaschandlers.com", 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, isVerified: false,
}, },
}); });
@ -152,6 +286,7 @@ async function main() {
code: "PART-TURBO-SEAL", code: "PART-TURBO-SEAL",
name: "Turbocharger Seal Kit", name: "Turbocharger Seal Kit",
description: "Replacement seal kit for main engine turbocharger", description: "Replacement seal kit for main engine turbocharger",
lastPrice: 1200,
}, },
}); });
@ -162,6 +297,7 @@ async function main() {
code: "PART-FP-PUMP", code: "PART-FP-PUMP",
name: "High-Pressure Fuel Pump", name: "High-Pressure Fuel Pump",
description: "Main engine high-pressure fuel pump assembly", description: "Main engine high-pressure fuel pump assembly",
lastPrice: 4800,
}, },
}); });
@ -172,6 +308,7 @@ async function main() {
code: "SAFE-LIFEJKT", code: "SAFE-LIFEJKT",
name: "Life Jacket (SOLAS)", name: "Life Jacket (SOLAS)",
description: "SOLAS-approved adult life jacket", description: "SOLAS-approved adult life jacket",
lastPrice: 120,
}, },
}); });
@ -182,6 +319,238 @@ async function main() {
code: "SAFE-EXTG-9KG", code: "SAFE-EXTG-9KG",
name: "Fire Extinguisher 9kg", name: "Fire Extinguisher 9kg",
description: "Dry powder 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,
}, },
}); });