feat: structured PO numbers, import closed, auto-vendor/product, company code, inventory flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-31 01:56:33 +05:30
parent 4cb927cbd0
commit 56b0490229
19 changed files with 376 additions and 96 deletions

View file

@ -10,6 +10,7 @@ type ActionResult = { ok: true } | { error: string };
const companySchema = z.object({ const companySchema = z.object({
name: z.string().min(1, "Company name is required"), name: z.string().min(1, "Company name is required"),
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
gstNumber: z.string().optional(), gstNumber: z.string().optional(),
address: z.string().optional(), address: z.string().optional(),
telephone: z.string().optional(), telephone: z.string().optional(),
@ -27,6 +28,7 @@ export async function createCompany(formData: FormData): Promise<ActionResult> {
const parsed = companySchema.safeParse({ const parsed = companySchema.safeParse({
name: formData.get("name"), name: formData.get("name"),
code: (formData.get("code") as string) || undefined,
gstNumber: (formData.get("gstNumber") as string) || undefined, gstNumber: (formData.get("gstNumber") as string) || undefined,
address: (formData.get("address") as string) || undefined, address: (formData.get("address") as string) || undefined,
telephone: (formData.get("telephone") as string) || undefined, telephone: (formData.get("telephone") as string) || undefined,
@ -37,9 +39,13 @@ export async function createCompany(formData: FormData): Promise<ActionResult> {
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const { name, gstNumber, address, telephone, mobile, email, invoiceEmail, invoiceAddress } = parsed.data; const { name, code, gstNumber, address, telephone, mobile, email, invoiceEmail, invoiceAddress } = parsed.data;
if (code) {
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } });
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
}
await db.company.create({ await db.company.create({
data: { name, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null }, data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
}); });
revalidatePath("/admin/companies"); revalidatePath("/admin/companies");
return { ok: true }; return { ok: true };
@ -56,6 +62,7 @@ export async function updateCompany(formData: FormData): Promise<ActionResult> {
const parsed = companySchema.safeParse({ const parsed = companySchema.safeParse({
name: formData.get("name"), name: formData.get("name"),
code: (formData.get("code") as string) || undefined,
gstNumber: (formData.get("gstNumber") as string) || undefined, gstNumber: (formData.get("gstNumber") as string) || undefined,
address: (formData.get("address") as string) || undefined, address: (formData.get("address") as string) || undefined,
telephone: (formData.get("telephone") as string) || undefined, telephone: (formData.get("telephone") as string) || undefined,
@ -66,10 +73,14 @@ export async function updateCompany(formData: FormData): Promise<ActionResult> {
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const { name, gstNumber, address, telephone, mobile, email, invoiceEmail, invoiceAddress } = parsed.data; const { name, code, gstNumber, address, telephone, mobile, email, invoiceEmail, invoiceAddress } = parsed.data;
if (code) {
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" }, id: { not: id } } });
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
}
await db.company.update({ await db.company.update({
where: { id }, where: { id },
data: { name, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null }, data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
}); });
revalidatePath("/admin/companies"); revalidatePath("/admin/companies");
return { ok: true }; return { ok: true };

View file

@ -10,6 +10,7 @@ import { deleteCompany, toggleCompanyActive } from "./actions";
export type CompanyRow = { export type CompanyRow = {
id: string; id: string;
name: string; name: string;
code: string | null;
gstNumber: string | null; gstNumber: string | null;
address: string | null; address: string | null;
telephone: string | null; telephone: string | null;
@ -84,7 +85,12 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
{companies.map((c) => ( {companies.map((c) => (
<tr key={c.id} className="hover:bg-neutral-50"> <tr key={c.id} className="hover:bg-neutral-50">
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium text-neutral-900">{c.name}</p> <div className="flex items-center gap-2">
{c.code && (
<span className="font-mono text-xs font-semibold text-primary-700 bg-primary-50 px-1.5 py-0.5 rounded">{c.code}</span>
)}
<p className="font-medium text-neutral-900">{c.name}</p>
</div>
{c.address && <p className="text-xs text-neutral-400 mt-0.5 truncate max-w-xs">{c.address}</p>} {c.address && <p className="text-xs text-neutral-400 mt-0.5 truncate max-w-xs">{c.address}</p>}
</td> </td>
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.gstNumber ?? <span className="italic text-neutral-400"></span>}</td> <td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.gstNumber ?? <span className="italic text-neutral-400"></span>}</td>

View file

@ -8,6 +8,7 @@ import { createCompany, updateCompany } from "./actions";
type CompanyRow = { type CompanyRow = {
id: string; id: string;
name: string; name: string;
code: string | null;
gstNumber: string | null; gstNumber: string | null;
address: string | null; address: string | null;
telephone: string | null; telephone: string | null;
@ -24,16 +25,24 @@ const LABEL = "block text-xs font-medium text-neutral-700 mb-1";
function CompanyFormFields({ company }: { company?: CompanyRow }) { function CompanyFormFields({ company }: { company?: CompanyRow }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div className="grid grid-cols-3 gap-3">
<label className={LABEL}>Company Name *</label> <div className="col-span-2">
<input name="name" defaultValue={company?.name} required className={INPUT} placeholder="e.g. Pelagia Marine Services Pvt. Ltd." /> <label className={LABEL}>Company Name *</label>
<input name="name" defaultValue={company?.name} required className={INPUT} placeholder="e.g. Pelagia Marine Services Pvt. Ltd." />
</div>
<div>
<label className={LABEL}>Code * <span className="font-normal text-neutral-400">(used in PO numbers)</span></label>
<input name="code" defaultValue={company?.code ?? ""} required maxLength={10}
className={`${INPUT} uppercase`} placeholder="e.g. PMS"
style={{ textTransform: "uppercase" }} />
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className={LABEL}>GST Number</label> <label className={LABEL}>GST Number</label>
<input name="gstNumber" defaultValue={company?.gstNumber ?? ""} className={INPUT} placeholder="e.g. 27AAHCP5787B1Z6" /> <input name="gstNumber" defaultValue={company?.gstNumber ?? ""} className={INPUT} placeholder="e.g. 27AAHCP5787B1Z6" />
</div> </div>
<div> <div className="col-span-2">
<label className={LABEL}>Contact Email</label> <label className={LABEL}>Contact Email</label>
<input name="email" type="email" defaultValue={company?.email ?? ""} className={INPUT} placeholder="contact@company.com" /> <input name="email" type="email" defaultValue={company?.email ?? ""} className={INPUT} placeholder="contact@company.com" />
</div> </div>

View file

@ -21,6 +21,7 @@ export default async function AdminCompaniesPage() {
companies={companies.map((c) => ({ companies={companies.map((c) => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
code: c.code,
gstNumber: c.gstNumber, gstNumber: c.gstNumber,
address: c.address, address: c.address,
telephone: c.telephone, telephone: c.telephone,

View file

@ -7,6 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
import { EditSiteButton } from "../site-form"; import { EditSiteButton } from "../site-form";
import { SiteCharts } from "./site-charts"; import { SiteCharts } from "./site-charts";
import { ConsumptionForm } from "./consumption-form"; import { ConsumptionForm } from "./consumption-form";
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
import type { Metadata } from "next"; import type { Metadata } from "next";
interface Props { params: Promise<{ id: string }> } interface Props { params: Promise<{ id: string }> }
@ -118,50 +119,55 @@ export default async function SiteDetailPage({ params }: Props) {
</div> </div>
</div> </div>
{/* Charts */} {/* Inventory tracking — hidden when NEXT_PUBLIC_INVENTORY_ENABLED=false */}
{(inventoryChartData.length > 0 || consumptionChartData.length > 0) && ( {INVENTORY_ENABLED && (
<SiteCharts inventoryData={inventoryChartData} consumptionData={consumptionChartData} /> <>
{/* Charts */}
{(inventoryChartData.length > 0 || consumptionChartData.length > 0) && (
<SiteCharts inventoryData={inventoryChartData} consumptionData={consumptionChartData} />
)}
{/* Inventory table */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Inventory at this site</h2>
{site.inventory.length === 0 ? (
<p className="text-sm text-neutral-400 italic">No inventory tracked yet.</p>
) : (
<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">Item</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Code</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Qty on hand</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{site.inventory.map((inv) => (
<tr key={inv.id}>
<td className="py-2 pr-4">
<Link href={`/admin/products/${inv.product.id}`} className="font-medium text-primary-600 hover:underline">
{inv.product.name}
</Link>
</td>
<td className="py-2 pl-4 font-mono text-xs text-neutral-500">{inv.product.code}</td>
<td className="py-2 pl-4 text-right font-semibold text-neutral-900">{Number(inv.quantity)}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(inv.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Record consumption */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Record Daily Consumption</h2>
<ConsumptionForm siteId={site.id} products={products} />
</div>
</>
)} )}
{/* Inventory table */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Inventory at this site</h2>
{site.inventory.length === 0 ? (
<p className="text-sm text-neutral-400 italic">No inventory tracked yet. Updated automatically when POs are delivered here.</p>
) : (
<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">Item</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Code</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Qty on hand</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{site.inventory.map((inv) => (
<tr key={inv.id}>
<td className="py-2 pr-4">
<Link href={`/admin/products/${inv.product.id}`} className="font-medium text-primary-600 hover:underline">
{inv.product.name}
</Link>
</td>
<td className="py-2 pl-4 font-mono text-xs text-neutral-500">{inv.product.code}</td>
<td className="py-2 pl-4 text-right font-semibold text-neutral-900">{Number(inv.quantity)}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(inv.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Record consumption */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Record Daily Consumption</h2>
<ConsumptionForm siteId={site.id} products={products} />
</div>
{/* Recent POs */} {/* Recent POs */}
{site.purchaseOrders.length > 0 && ( {site.purchaseOrders.length > 0 && (

View file

@ -51,7 +51,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } }, select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}), }),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
]); ]);
if (!po) notFound(); if (!po) notFound();
@ -97,7 +97,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
vessels={vessels} vessels={vessels}
accounts={accounts} accounts={accounts}
vendors={vendors} vendors={vendors}
companies={companies as CompanyOption[]} companies={companies}
/> />
</div> </div>

View file

@ -37,7 +37,7 @@ export default async function EditPoPage({ params }: Props) {
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } }, select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}), }),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
po.status === "EDITS_REQUESTED" po.status === "EDITS_REQUESTED"
? db.pOAction.findFirst({ ? db.pOAction.findFirst({
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } }, where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
@ -72,7 +72,7 @@ export default async function EditPoPage({ params }: Props) {
vessels={vessels} vessels={vessels}
accounts={accounts} accounts={accounts}
vendors={vendors} vendors={vendors}
companies={companies as CompanyOption[]} companies={companies}
managerNoteAuthor={noteAction?.actor.name ?? null} managerNoteAuthor={noteAction?.actor.name ?? null}
/> />
</div> </div>

View file

@ -3,7 +3,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { generatePoNumber } from "@/lib/utils"; import { generatePoNumber } from "@/lib/po-number";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import type { ParsedImportLine } from "@/app/api/po/import/route"; import type { ParsedImportLine } from "@/app/api/po/import/route";
@ -12,7 +12,12 @@ export type ImportPoInput = {
vesselId: string; vesselId: string;
accountId: string; accountId: string;
companyId?: string; companyId?: string;
/** vendorId of an existing vendor, if pre-matched in the UI */
vendorId?: string; vendorId?: string;
/** Raw vendor name from the Excel — used to auto-create if no vendorId matched */
parsedVendorName?: string;
parsedVendorAddress?: string;
parsedVendorContact?: string;
piQuotationNo?: string; piQuotationNo?: string;
placeOfDelivery?: string; placeOfDelivery?: string;
tcDelivery?: string; tcDelivery?: string;
@ -33,22 +38,103 @@ export async function importPo(
return { error: "You do not have permission to import purchase orders." }; return { error: "You do not have permission to import purchase orders." };
} }
const total = input.lineItems.reduce( const now = new Date();
// ── 1. Resolve / auto-create vendor ───────────────────────────────────────
let resolvedVendorId: string | null = input.vendorId ?? null;
if (!resolvedVendorId && input.parsedVendorName) {
// Try case-insensitive match first
const existing = await db.vendor.findFirst({
where: { name: { equals: input.parsedVendorName, mode: "insensitive" } },
select: { id: true },
});
if (existing) {
resolvedVendorId = existing.id;
} else {
// Auto-create vendor from imported data
const newVendor = await db.vendor.create({
data: {
name: input.parsedVendorName,
address: input.parsedVendorAddress || null,
contacts: input.parsedVendorContact
? {
create: {
name: input.parsedVendorContact,
isPrimary: true,
},
}
: undefined,
},
});
resolvedVendorId = newVendor.id;
}
}
// ── 2. Resolve / auto-create products ─────────────────────────────────────
const resolvedLineItems: Array<
ParsedImportLine & { productId?: string }
> = [];
for (const item of input.lineItems) {
const existing = await db.product.findFirst({
where: { name: { equals: item.name, mode: "insensitive" } },
select: { id: true },
});
let productId: string | undefined;
if (existing) {
productId = existing.id;
// Update lastPrice if we have a better price
if (item.unitPrice > 0) {
await db.product.update({
where: { id: existing.id },
data: {
lastPrice: item.unitPrice,
lastVendorId: resolvedVendorId ?? undefined,
},
});
}
} else {
// Auto-create product
const count = await db.product.count();
const code = `PROD-${String(count + 1).padStart(4, "0")}`;
const newProduct = await db.product.create({
data: {
code,
name: item.name,
lastPrice: item.unitPrice > 0 ? item.unitPrice : null,
lastVendorId: resolvedVendorId ?? null,
},
});
productId = newProduct.id;
}
resolvedLineItems.push({ ...item, productId });
}
// ── 3. Calculate total ─────────────────────────────────────────────────────
const total = resolvedLineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)), (sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)),
0 0
); );
// ── 4. Generate structured PO number ─────────────────────────────────────
const poNumber = await generatePoNumber(input.vesselId, input.companyId);
// ── 5. Create PO in CLOSED state ──────────────────────────────────────────
// Imported POs bypass the approval workflow — they are historical records.
const po = await db.purchaseOrder.create({ const po = await db.purchaseOrder.create({
data: { data: {
poNumber: generatePoNumber(), poNumber,
title: input.title, title: input.title,
status: "DRAFT", status: "CLOSED",
totalAmount: total, totalAmount: total,
currency: "INR", currency: "INR",
vesselId: input.vesselId, vesselId: input.vesselId,
accountId: input.accountId, accountId: input.accountId,
companyId: input.companyId ?? null, companyId: input.companyId ?? null,
vendorId: input.vendorId ?? null, vendorId: resolvedVendorId,
piQuotationNo: input.piQuotationNo ?? null, piQuotationNo: input.piQuotationNo ?? null,
placeOfDelivery: input.placeOfDelivery ?? null, placeOfDelivery: input.placeOfDelivery ?? null,
tcDelivery: input.tcDelivery ?? null, tcDelivery: input.tcDelivery ?? null,
@ -58,8 +144,12 @@ export async function importPo(
tcPaymentTerms: input.tcPaymentTerms ?? null, tcPaymentTerms: input.tcPaymentTerms ?? null,
tcOthers: input.tcOthers ?? null, tcOthers: input.tcOthers ?? null,
submitterId: session.user.id, submitterId: session.user.id,
submittedAt: now,
approvedAt: now,
paidAt: now,
closedAt: now,
lineItems: { lineItems: {
create: input.lineItems.map((item, idx) => ({ create: resolvedLineItems.map((item, idx) => ({
name: item.name, name: item.name,
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
@ -67,15 +157,21 @@ export async function importPo(
totalPrice: item.quantity * item.unitPrice, totalPrice: item.quantity * item.unitPrice,
gstRate: item.gstRate ?? 0.18, gstRate: item.gstRate ?? 0.18,
sortOrder: idx, sortOrder: idx,
productId: item.productId ?? null,
})), })),
}, },
actions: { actions: {
create: { actionType: "CREATED", actorId: session.user.id }, create: [
{ actionType: "CREATED", actorId: session.user.id, createdAt: now },
{ actionType: "SUBMITTED", actorId: session.user.id, createdAt: now },
{ actionType: "APPROVED", actorId: session.user.id, createdAt: now },
{ actionType: "CLOSED", actorId: session.user.id, createdAt: now },
],
}, },
}, },
}); });
revalidatePath("/my-orders"); revalidatePath("/history");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { id: po.id }; return { id: po.id };
} }

View file

@ -57,26 +57,40 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
} }
const parsed: ParsedImport = data.results[0]; const parsed: ParsedImport = data.results[0];
// Auto-match vendor by name (case-insensitive substring) // Auto-match vendor by name (case-insensitive substring)
const matchedVendor = vendors.find( const matchedVendor = vendors.find(
(v) => v.isActive && parsed.vendorName && (v) => v.isActive && parsed.vendorName &&
v.name.toLowerCase().includes(parsed.vendorName.toLowerCase().slice(0, 10)) v.name.toLowerCase().includes(parsed.vendorName.toLowerCase().slice(0, 10))
); );
// Auto-detect company from Excel row 1 (company name header) // Auto-match company: prefer exact code match, then name fuzzy match
const matchedCompany = parsed.companyName const matchedCompany = parsed.companyCode
? companies.find((c) => ? companies.find((c) => c.code?.toUpperCase() === parsed.companyCode?.toUpperCase())
c.name.toLowerCase().includes(parsed.companyName.toLowerCase().slice(0, 8)) || ?? companies.find((c) =>
parsed.companyName.toLowerCase().includes(c.name.toLowerCase().slice(0, 8)) parsed.companyName && (
) c.name.toLowerCase().includes(parsed.companyName.toLowerCase().slice(0, 8)) ||
: undefined; parsed.companyName.toLowerCase().includes(c.name.toLowerCase().slice(0, 8))
)
)
: companies.find((c) =>
parsed.companyName && (
c.name.toLowerCase().includes(parsed.companyName.toLowerCase().slice(0, 8)) ||
parsed.companyName.toLowerCase().includes(c.name.toLowerCase().slice(0, 8))
)
);
// Auto-match vessel: prefer exact code match from PO number
const matchedVessel = parsed.costCentreCode
? vessels.find((v) => v.code.toUpperCase() === parsed.costCentreCode!.toUpperCase())
: null;
setPreview({ setPreview({
parsed, parsed,
title: parsed.vendorName title: parsed.vendorName
? `${parsed.vendorName} — Import` ? `${parsed.vendorName} — Import`
: "Imported Purchase Order", : "Imported Purchase Order",
vesselId: vessels[0]?.id ?? "", vesselId: matchedVessel?.id ?? vessels[0]?.id ?? "",
accountId: accounts[0]?.items[0]?.id ?? "", accountId: accounts[0]?.items[0]?.id ?? "",
vendorId: matchedVendor?.id ?? "", vendorId: matchedVendor?.id ?? "",
companyId: matchedCompany?.id ?? (companies[0]?.id ?? ""), companyId: matchedCompany?.id ?? (companies[0]?.id ?? ""),
@ -100,6 +114,9 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
companyId: preview.companyId || undefined, companyId: preview.companyId || undefined,
accountId: preview.accountId, accountId: preview.accountId,
vendorId: preview.vendorId || undefined, vendorId: preview.vendorId || undefined,
parsedVendorName: preview.parsed.vendorName || undefined,
parsedVendorAddress: preview.parsed.vendorAddress || undefined,
parsedVendorContact: preview.parsed.vendorContact || undefined,
piQuotationNo: preview.parsed.piQuotationNo || undefined, piQuotationNo: preview.parsed.piQuotationNo || undefined,
placeOfDelivery: preview.parsed.placeOfDelivery || undefined, placeOfDelivery: preview.parsed.placeOfDelivery || undefined,
tcDelivery: preview.parsed.tcDelivery || undefined, tcDelivery: preview.parsed.tcDelivery || undefined,
@ -169,11 +186,21 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Extracted data banner */} {/* Extracted data banner */}
<div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3 text-sm text-primary-800"> <div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3 text-sm text-primary-800 space-y-1">
<span className="font-semibold">Parsed from file.</span>{" "} <div>
{parsed.vendorName && <>Vendor: <strong>{parsed.vendorName}</strong>. </>} <span className="font-semibold">Parsed from file.</span>{" "}
{parsed.piQuotationNo && <>Quotation: <strong>{parsed.piQuotationNo}</strong>. </>} {parsed.vendorName && <>Vendor: <strong>{parsed.vendorName}</strong>. </>}
Review and fill in the fields below, then click &ldquo;Create as Draft&rdquo;. {parsed.piQuotationNo && <>Quotation: <strong>{parsed.piQuotationNo}</strong>. </>}
This PO will be saved directly as <strong>Closed</strong> no approval needed.
</div>
{parsed.poNumber && (
<div className="text-xs text-primary-700">
PO Number: <span className="font-mono font-semibold">{parsed.poNumber}</span>
{parsed.companyCode && <> · Company: <strong>{parsed.companyCode}</strong></>}
{parsed.costCentreCode && <> · Cost Centre: <strong>{parsed.costCentreCode}</strong></>}
{parsed.poSequenceId !== null && <> · ID: <strong>{parsed.poSequenceId}</strong></>}
</div>
)}
</div> </div>
{/* User-required fields */} {/* User-required fields */}
@ -259,8 +286,8 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
))} ))}
</select> </select>
{parsed.vendorName && !preview.vendorId && ( {parsed.vendorName && !preview.vendorId && (
<p className="mt-1 text-xs text-warning-700"> <p className="mt-1 text-xs text-success-700 bg-success-50 rounded px-2 py-1">
Extracted vendor &ldquo;{parsed.vendorName}&rdquo; no match found. Assign or add from Vendor Registry. Vendor &ldquo;{parsed.vendorName}&rdquo; will be auto-created on submit.
</p> </p>
)} )}
</div> </div>
@ -268,9 +295,14 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
{/* Line items preview */} {/* Line items preview */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4"> <div className="flex items-center justify-between mb-4">
Line Items <span className="ml-2 text-sm font-normal text-neutral-500">({parsed.lineItems.length} items)</span> <h2 className="text-base font-semibold text-neutral-900">
</h2> Line Items <span className="ml-2 text-sm font-normal text-neutral-500">({parsed.lineItems.length} items)</span>
</h2>
<p className="text-xs text-success-700 bg-success-50 rounded px-2 py-1">
Products will be auto-created in the catalogue on submit
</p>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@ -326,7 +358,7 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
disabled={submitting || !preview.vesselId || !preview.accountId || (companies.length > 0 && !preview.companyId)} disabled={submitting || !preview.vesselId || !preview.accountId || (companies.length > 0 && !preview.companyId)}
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" 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"} {submitting ? "Importing…" : "Import & Close PO"}
</button> </button>
</div> </div>
</form> </form>

View file

@ -22,7 +22,7 @@ export default async function ImportPoPage() {
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } }, select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}), }),
db.vendor.findMany({ orderBy: { name: "asc" } }), db.vendor.findMany({ orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
]); ]);
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);
@ -33,7 +33,7 @@ export default async function ImportPoPage() {
<h1 className="text-2xl font-semibold text-neutral-900">Import Purchase Order</h1> <h1 className="text-2xl font-semibold text-neutral-900">Import Purchase Order</h1>
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 text-sm text-neutral-500">
Upload a Pelagia-format Excel PO file. Line items and vendor details are extracted automatically. Upload a Pelagia-format Excel PO file. Line items and vendor details are extracted automatically.
You then select the cost centre, accounting code, and confirm before saving as a draft. Vendor and products are auto-created if not found. The PO is saved directly as Closed no approval needed.
</p> </p>
</div> </div>
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} companies={companies} /> <ImportForm vessels={vessels} accounts={accounts} vendors={vendors} companies={companies} />

View file

@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { requirePermission } from "@/lib/permissions"; import { requirePermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { generatePoNumber } from "@/lib/utils"; import { generatePoNumber } from "@/lib/po-number";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -84,7 +84,7 @@ export async function createPo(
const po = await db.purchaseOrder.create({ const po = await db.purchaseOrder.create({
data: { data: {
poNumber: generatePoNumber(), poNumber: await generatePoNumber(data.vesselId, data.companyId),
title: data.title, title: data.title,
status: intent === "submit" ? "SUBMITTED" : "DRAFT", status: intent === "submit" ? "SUBMITTED" : "DRAFT",
totalAmount: total, totalAmount: total,

View file

@ -13,7 +13,7 @@ import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/p
export type VesselOption = { id: string; code: string; name: string }; export type VesselOption = { id: string; code: string; name: string };
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] }; export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
export type CompanyOption = { id: string; name: string }; export type CompanyOption = { id: string; name: string; code: string | null };
const INPUT_CLS = 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"; "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";

View file

@ -54,7 +54,7 @@ export default async function NewPoPage({ searchParams }: Props) {
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } }, select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}), }),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
]); ]);
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);

View file

@ -2,6 +2,7 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LayoutDashboard, LayoutDashboard,
@ -45,16 +46,24 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/profile", label: "My Profile", icon: UserCircle }, { href: "/profile", label: "My Profile", icon: UserCircle },
]; ];
const INVENTORY_ITEMS: NavItem[] = [ // Vendor/product/cart nav — always visible (needed for PO creation)
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, const PO_CATALOGUE_ITEMS: NavItem[] = [
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, { href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] }, { href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] }, { href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] }, { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/vessels", label: "Vessels", icon: Ship, roles: ["MANAGER", "ADMIN"] }, { href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] }, { href: "/admin/vessels", label: "Vessels", icon: Ship, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
]; ];
// Inventory tracking nav — hidden when NEXT_PUBLIC_INVENTORY_ENABLED=false
const INVENTORY_TRACKING_ITEMS: NavItem[] = []; // reserved for future inventory-only links
const INVENTORY_ITEMS: NavItem[] = INVENTORY_ENABLED
? [...PO_CATALOGUE_ITEMS, ...INVENTORY_TRACKING_ITEMS]
: PO_CATALOGUE_ITEMS;
const ADMIN_ITEMS: NavItem[] = [ const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/users", label: "Users", icon: Users }, { href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck }, { href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },

10
App/lib/feature-flags.ts Normal file
View file

@ -0,0 +1,10 @@
/**
* Feature flags read from environment variables.
* NEXT_PUBLIC_ prefix makes them available in both server and client components.
*
* NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption)
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
*/
export const INVENTORY_ENABLED =
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";

View file

@ -10,6 +10,10 @@ export type ParsedImportLine = {
export type ParsedImport = { export type ParsedImport = {
companyName: string; companyName: string;
/** Extracted from structured PO number (COMPANY/VESSEL/ID/FY). Null for legacy formats. */
companyCode: string | null;
costCentreCode: string | null;
poSequenceId: number | null;
poNumber: string; poNumber: string;
piQuotationNo: string; piQuotationNo: string;
placeOfDelivery: string; placeOfDelivery: string;
@ -40,10 +44,27 @@ export function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number
return isNaN(v) ? 0 : v; return isNaN(v) ? 0 : v;
} }
/** Parse a structured PO number (COMPANY/VESSEL/ID/FY) into its parts. */
function parsePoNumberParts(poNumber: string): {
companyCode: string | null;
costCentreCode: string | null;
poSequenceId: number | null;
} {
const parts = poNumber.split("/");
if (parts.length !== 4) return { companyCode: null, costCentreCode: null, poSequenceId: null };
const poSequenceId = parseInt(parts[2], 10);
return {
companyCode: parts[0] || null,
costCentreCode: parts[1] || null,
poSequenceId: isNaN(poSequenceId) ? null : poSequenceId,
};
}
export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport { export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
// Row 1 (index 0) = company name, spanning the full header (col 0) // Row 1 (index 0) = company name, spanning the full header (col 0)
const companyName = cellStr(sheet, 0, 0); const companyName = cellStr(sheet, 0, 0);
const poNumber = cellStr(sheet, 4, 2); const poNumber = cellStr(sheet, 4, 2);
const { companyCode, costCentreCode, poSequenceId } = parsePoNumberParts(poNumber);
const piQuotationNo = cellStr(sheet, 5, 2); const piQuotationNo = cellStr(sheet, 5, 2);
const placeOfDelivery = cellStr(sheet, 8, 2); const placeOfDelivery = cellStr(sheet, 8, 2);
const vendorName = cellStr(sheet, 12, 2); const vendorName = cellStr(sheet, 12, 2);
@ -92,6 +113,9 @@ export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
return { return {
companyName, companyName,
companyCode,
costCentreCode,
poSequenceId,
poNumber, poNumber,
piQuotationNo, piQuotationNo,
placeOfDelivery, placeOfDelivery,

72
App/lib/po-number.ts Normal file
View file

@ -0,0 +1,72 @@
/**
* Structured PO number generator.
* Format: COMPANY_CODE/VESSEL_CODE/PO_ID/FY
* - COMPANY_CODE: company.code (fallback "PMS")
* - VESSEL_CODE: vessel.code (fallback "GEN")
* - PO_ID: globally sequential integer, starting from 200
* - FY: Indian financial year "XXYY" e.g. "2526" for Apr 2025Mar 2026
*
* Example: PMS/HNR1/200/2526
*/
import { db } from "@/lib/db";
/** Indian financial year string. AprilMarch cycle. */
function currentFY(): string {
const now = new Date();
const month = now.getMonth() + 1; // 1-indexed
const year = now.getFullYear();
const fyStart = month >= 4 ? year : year - 1;
const fyEnd = fyStart + 1;
return `${String(fyStart).slice(-2)}${String(fyEnd).slice(-2)}`;
}
/** Find the next sequential PO ID (min 200) by scanning existing structured PO numbers. */
async function nextPoId(): Promise<number> {
const pos = await db.purchaseOrder.findMany({ select: { poNumber: true } });
let maxId = 199;
for (const { poNumber } of pos) {
const parts = poNumber.split("/");
if (parts.length === 4) {
const n = parseInt(parts[2], 10);
if (!isNaN(n) && n > maxId) maxId = n;
}
}
return maxId + 1;
}
/**
* Generate a structured PO number.
* Pass vesselId and companyId so we can resolve their codes from the DB.
* Either may be null sensible defaults are used.
*/
export async function generatePoNumber(
vesselId?: string | null,
companyId?: string | null,
): Promise<string> {
const [vessel, company, id] = await Promise.all([
vesselId ? db.vessel .findUnique({ where: { id: vesselId }, select: { code: true } }) : null,
companyId ? db.company.findUnique({ where: { id: companyId }, select: { code: true } }) : null,
nextPoId(),
]);
const companyCode = company?.code ?? "PMS";
const vesselCode = vessel?.code ?? "GEN";
const fy = currentFY();
return `${companyCode}/${vesselCode}/${id}/${fy}`;
}
/** Parse a structured PO number into its parts. Returns null for old-format numbers. */
export function parsePoNumber(poNumber: string): {
companyCode: string;
vesselCode: string;
poId: number;
fy: string;
} | null {
const parts = poNumber.split("/");
if (parts.length !== 4) return null;
const poId = parseInt(parts[2], 10);
if (isNaN(poId)) return null;
return { companyCode: parts[0], vesselCode: parts[1], poId, fy: parts[3] };
}

View file

@ -0,0 +1,3 @@
-- Add short code to Company (used in PO number format: CODE/VESSEL/ID/FY)
ALTER TABLE "Company" ADD COLUMN "code" TEXT;
CREATE UNIQUE INDEX "Company_code_key" ON "Company"("code");

View file

@ -117,6 +117,7 @@ model Vessel {
model Company { model Company {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
code String? @unique
gstNumber String? gstNumber String?
address String? address String?
telephone String? telephone String?