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." };
|
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: {
|
||||||
|
|
|
||||||
|
|
@ -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 “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 />
|
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
|
||||||
|
|
||||||
<ManagerEditPoForm
|
<ManagerEditPoForm
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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" } })
|
||||||
|
|
|
||||||
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;
|
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: {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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,
|
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[] = [
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue