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:
parent
17586e6ea1
commit
43f0861591
17 changed files with 1121 additions and 14 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
</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 “Request Vendor ID” 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 />
|
||||
|
||||
<ManagerEditPoForm
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default async function MyOrdersPage() {
|
|||
if (!session?.user) redirect("/login");
|
||||
|
||||
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({
|
||||
where: { submitterId: userId },
|
||||
|
|
|
|||
|
|
@ -45,8 +45,10 @@ export default async function PoDetailPage({ params }: Props) {
|
|||
|
||||
const canProvideVendorId =
|
||||
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
|
||||
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
|
||||
|
|
|
|||
79
App/pelagia-portal/app/(portal)/po/import/actions.ts
Normal file
79
App/pelagia-portal/app/(portal)/po/import/actions.ts
Normal 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 };
|
||||
}
|
||||
296
App/pelagia-portal/app/(portal)/po/import/import-form.tsx
Normal file
296
App/pelagia-portal/app/(portal)/po/import/import-form.tsx
Normal 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 “Create as Draft”.
|
||||
</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 “{parsed.vendorName}” — 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>
|
||||
);
|
||||
}
|
||||
34
App/pelagia-portal/app/(portal)/po/import/page.tsx
Normal file
34
App/pelagia-portal/app/(portal)/po/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
156
App/pelagia-portal/app/api/po/import/route.ts
Normal file
156
App/pelagia-portal/app/api/po/import/route.ts
Normal 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 });
|
||||
}
|
||||
32
App/pelagia-portal/app/api/products/search/route.ts
Normal file
32
App/pelagia-portal/app/api/products/search/route.ts
Normal 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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
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)));
|
||||
}
|
||||
|
||||
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 (
|
||||
<tr key={i}>
|
||||
<td className="py-2 pr-4">
|
||||
<input
|
||||
<DescriptionCell
|
||||
value={row.description}
|
||||
onChange={(e) => 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)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
|
|
|
|||
|
|
@ -22,8 +22,12 @@ export type Permission =
|
|||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
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<Role, Permission[]> = {
|
|||
"request_vendor_id",
|
||||
"view_analytics",
|
||||
"export_reports",
|
||||
"manage_vendors",
|
||||
],
|
||||
SUPERUSER: [
|
||||
"create_po",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
|
|||
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<Record<POStatus, TransitionMap>> = {
|
|||
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<Record<POStatus, TransitionMap>> = {
|
|||
EDITS_REQUESTED: {
|
||||
submit: {
|
||||
to: "SUBMITTED",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_MANAGER"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue