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:
parent
4cb927cbd0
commit
56b0490229
19 changed files with 376 additions and 96 deletions
|
|
@ -10,6 +10,7 @@ type ActionResult = { ok: true } | { error: string };
|
|||
|
||||
const companySchema = z.object({
|
||||
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(),
|
||||
address: z.string().optional(),
|
||||
telephone: z.string().optional(),
|
||||
|
|
@ -27,6 +28,7 @@ export async function createCompany(formData: FormData): Promise<ActionResult> {
|
|||
|
||||
const parsed = companySchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
code: (formData.get("code") as string) || undefined,
|
||||
gstNumber: (formData.get("gstNumber") as string) || undefined,
|
||||
address: (formData.get("address") 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" };
|
||||
|
||||
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({
|
||||
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");
|
||||
return { ok: true };
|
||||
|
|
@ -56,6 +62,7 @@ export async function updateCompany(formData: FormData): Promise<ActionResult> {
|
|||
|
||||
const parsed = companySchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
code: (formData.get("code") as string) || undefined,
|
||||
gstNumber: (formData.get("gstNumber") as string) || undefined,
|
||||
address: (formData.get("address") 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" };
|
||||
|
||||
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({
|
||||
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");
|
||||
return { ok: true };
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { deleteCompany, toggleCompanyActive } from "./actions";
|
|||
export type CompanyRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
gstNumber: string | null;
|
||||
address: string | null;
|
||||
telephone: string | null;
|
||||
|
|
@ -84,7 +85,12 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
|
|||
{companies.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-neutral-50">
|
||||
<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>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.gstNumber ?? <span className="italic text-neutral-400">—</span>}</td>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { createCompany, updateCompany } from "./actions";
|
|||
type CompanyRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
gstNumber: string | null;
|
||||
address: 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 }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={LABEL}>Company Name *</label>
|
||||
<input name="name" defaultValue={company?.name} required className={INPUT} placeholder="e.g. Pelagia Marine Services Pvt. Ltd." />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<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 className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={LABEL}>GST Number</label>
|
||||
<input name="gstNumber" defaultValue={company?.gstNumber ?? ""} className={INPUT} placeholder="e.g. 27AAHCP5787B1Z6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL}>Contact Email</label>
|
||||
<input name="email" type="email" defaultValue={company?.email ?? ""} className={INPUT} placeholder="contact@company.com" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export default async function AdminCompaniesPage() {
|
|||
companies={companies.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
gstNumber: c.gstNumber,
|
||||
address: c.address,
|
||||
telephone: c.telephone,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
|
|||
import { EditSiteButton } from "../site-form";
|
||||
import { SiteCharts } from "./site-charts";
|
||||
import { ConsumptionForm } from "./consumption-form";
|
||||
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface Props { params: Promise<{ id: string }> }
|
||||
|
|
@ -118,50 +119,55 @@ export default async function SiteDetailPage({ params }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
{(inventoryChartData.length > 0 || consumptionChartData.length > 0) && (
|
||||
<SiteCharts inventoryData={inventoryChartData} consumptionData={consumptionChartData} />
|
||||
{/* Inventory tracking — hidden when NEXT_PUBLIC_INVENTORY_ENABLED=false */}
|
||||
{INVENTORY_ENABLED && (
|
||||
<>
|
||||
{/* 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 */}
|
||||
{site.purchaseOrders.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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 } } } } },
|
||||
}),
|
||||
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();
|
||||
|
|
@ -97,7 +97,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
vessels={vessels}
|
||||
accounts={accounts}
|
||||
vendors={vendors}
|
||||
companies={companies as CompanyOption[]}
|
||||
companies={companies}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } } } } },
|
||||
}),
|
||||
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"
|
||||
? db.pOAction.findFirst({
|
||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
||||
|
|
@ -72,7 +72,7 @@ export default async function EditPoPage({ params }: Props) {
|
|||
vessels={vessels}
|
||||
accounts={accounts}
|
||||
vendors={vendors}
|
||||
companies={companies as CompanyOption[]}
|
||||
companies={companies}
|
||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { generatePoNumber } from "@/lib/utils";
|
||||
import { generatePoNumber } from "@/lib/po-number";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ParsedImportLine } from "@/app/api/po/import/route";
|
||||
|
||||
|
|
@ -12,7 +12,12 @@ export type ImportPoInput = {
|
|||
vesselId: string;
|
||||
accountId: string;
|
||||
companyId?: string;
|
||||
/** vendorId of an existing vendor, if pre-matched in the UI */
|
||||
vendorId?: string;
|
||||
/** Raw vendor name from the Excel — used to auto-create if no vendorId matched */
|
||||
parsedVendorName?: string;
|
||||
parsedVendorAddress?: string;
|
||||
parsedVendorContact?: string;
|
||||
piQuotationNo?: string;
|
||||
placeOfDelivery?: string;
|
||||
tcDelivery?: string;
|
||||
|
|
@ -33,22 +38,103 @@ export async function importPo(
|
|||
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)),
|
||||
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({
|
||||
data: {
|
||||
poNumber: generatePoNumber(),
|
||||
poNumber,
|
||||
title: input.title,
|
||||
status: "DRAFT",
|
||||
status: "CLOSED",
|
||||
totalAmount: total,
|
||||
currency: "INR",
|
||||
vesselId: input.vesselId,
|
||||
accountId: input.accountId,
|
||||
companyId: input.companyId ?? null,
|
||||
vendorId: input.vendorId ?? null,
|
||||
vendorId: resolvedVendorId,
|
||||
piQuotationNo: input.piQuotationNo ?? null,
|
||||
placeOfDelivery: input.placeOfDelivery ?? null,
|
||||
tcDelivery: input.tcDelivery ?? null,
|
||||
|
|
@ -58,8 +144,12 @@ export async function importPo(
|
|||
tcPaymentTerms: input.tcPaymentTerms ?? null,
|
||||
tcOthers: input.tcOthers ?? null,
|
||||
submitterId: session.user.id,
|
||||
submittedAt: now,
|
||||
approvedAt: now,
|
||||
paidAt: now,
|
||||
closedAt: now,
|
||||
lineItems: {
|
||||
create: input.lineItems.map((item, idx) => ({
|
||||
create: resolvedLineItems.map((item, idx) => ({
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
|
|
@ -67,15 +157,21 @@ export async function importPo(
|
|||
totalPrice: item.quantity * item.unitPrice,
|
||||
gstRate: item.gstRate ?? 0.18,
|
||||
sortOrder: idx,
|
||||
productId: item.productId ?? null,
|
||||
})),
|
||||
},
|
||||
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");
|
||||
return { id: po.id };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,26 +57,40 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
|||
}
|
||||
|
||||
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))
|
||||
);
|
||||
|
||||
// Auto-detect company from Excel row 1 (company name header)
|
||||
const matchedCompany = parsed.companyName
|
||||
? companies.find((c) =>
|
||||
c.name.toLowerCase().includes(parsed.companyName.toLowerCase().slice(0, 8)) ||
|
||||
parsed.companyName.toLowerCase().includes(c.name.toLowerCase().slice(0, 8))
|
||||
)
|
||||
: undefined;
|
||||
// Auto-match company: prefer exact code match, then name fuzzy match
|
||||
const matchedCompany = parsed.companyCode
|
||||
? companies.find((c) => c.code?.toUpperCase() === parsed.companyCode?.toUpperCase())
|
||||
?? 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))
|
||||
)
|
||||
)
|
||||
: 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({
|
||||
parsed,
|
||||
title: parsed.vendorName
|
||||
? `${parsed.vendorName} — Import`
|
||||
: "Imported Purchase Order",
|
||||
vesselId: vessels[0]?.id ?? "",
|
||||
vesselId: matchedVessel?.id ?? vessels[0]?.id ?? "",
|
||||
accountId: accounts[0]?.items[0]?.id ?? "",
|
||||
vendorId: matchedVendor?.id ?? "",
|
||||
companyId: matchedCompany?.id ?? (companies[0]?.id ?? ""),
|
||||
|
|
@ -100,6 +114,9 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
|||
companyId: preview.companyId || undefined,
|
||||
accountId: preview.accountId,
|
||||
vendorId: preview.vendorId || undefined,
|
||||
parsedVendorName: preview.parsed.vendorName || undefined,
|
||||
parsedVendorAddress: preview.parsed.vendorAddress || undefined,
|
||||
parsedVendorContact: preview.parsed.vendorContact || undefined,
|
||||
piQuotationNo: preview.parsed.piQuotationNo || undefined,
|
||||
placeOfDelivery: preview.parsed.placeOfDelivery || undefined,
|
||||
tcDelivery: preview.parsed.tcDelivery || undefined,
|
||||
|
|
@ -169,11 +186,21 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
|||
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 className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3 text-sm text-primary-800 space-y-1">
|
||||
<div>
|
||||
<span className="font-semibold">Parsed from file.</span>{" "}
|
||||
{parsed.vendorName && <>Vendor: <strong>{parsed.vendorName}</strong>. </>}
|
||||
{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>
|
||||
|
||||
{/* User-required fields */}
|
||||
|
|
@ -259,8 +286,8 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
|||
))}
|
||||
</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 className="mt-1 text-xs text-success-700 bg-success-50 rounded px-2 py-1">
|
||||
✓ Vendor “{parsed.vendorName}” will be auto-created on submit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -268,9 +295,14 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
|||
|
||||
{/* 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="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-neutral-900">
|
||||
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">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
|
|
@ -326,7 +358,7 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
|||
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"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create as Draft"}
|
||||
{submitting ? "Importing…" : "Import & Close PO"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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 } } } } },
|
||||
}),
|
||||
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);
|
||||
|
|
@ -33,7 +33,7 @@ export default async function ImportPoPage() {
|
|||
<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 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>
|
||||
</div>
|
||||
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} companies={companies} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { requirePermission } from "@/lib/permissions";
|
||||
import { createPoSchema } from "@/lib/validations/po";
|
||||
import { generatePoNumber } from "@/lib/utils";
|
||||
import { generatePoNumber } from "@/lib/po-number";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ export async function createPo(
|
|||
|
||||
const po = await db.purchaseOrder.create({
|
||||
data: {
|
||||
poNumber: generatePoNumber(),
|
||||
poNumber: await generatePoNumber(data.vesselId, data.companyId),
|
||||
title: data.title,
|
||||
status: intent === "submit" ? "SUBMITTED" : "DRAFT",
|
||||
totalAmount: total,
|
||||
|
|
|
|||
|
|
@ -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 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 =
|
||||
"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";
|
||||
|
|
|
|||
|
|
@ -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 } } } } },
|
||||
}),
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -45,16 +46,24 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
||||
];
|
||||
|
||||
const INVENTORY_ITEMS: NavItem[] = [
|
||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||
{ href: "/admin/vessels", label: "Vessels", icon: Ship, roles: ["MANAGER", "ADMIN"] },
|
||||
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
|
||||
// Vendor/product/cart nav — always visible (needed for PO creation)
|
||||
const PO_CATALOGUE_ITEMS: NavItem[] = [
|
||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||
{ href: "/admin/products", label: "Items", icon: Package, 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[] = [
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
||||
|
|
|
|||
10
App/lib/feature-flags.ts
Normal file
10
App/lib/feature-flags.ts
Normal 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";
|
||||
|
|
@ -10,6 +10,10 @@ export type ParsedImportLine = {
|
|||
|
||||
export type ParsedImport = {
|
||||
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;
|
||||
piQuotationNo: string;
|
||||
placeOfDelivery: string;
|
||||
|
|
@ -40,10 +44,27 @@ export function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number
|
|||
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 {
|
||||
// Row 1 (index 0) = company name, spanning the full header (col 0)
|
||||
const companyName = cellStr(sheet, 0, 0);
|
||||
const poNumber = cellStr(sheet, 4, 2);
|
||||
const { companyCode, costCentreCode, poSequenceId } = parsePoNumberParts(poNumber);
|
||||
const piQuotationNo = cellStr(sheet, 5, 2);
|
||||
const placeOfDelivery = cellStr(sheet, 8, 2);
|
||||
const vendorName = cellStr(sheet, 12, 2);
|
||||
|
|
@ -92,6 +113,9 @@ export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
|
|||
|
||||
return {
|
||||
companyName,
|
||||
companyCode,
|
||||
costCentreCode,
|
||||
poSequenceId,
|
||||
poNumber,
|
||||
piQuotationNo,
|
||||
placeOfDelivery,
|
||||
|
|
|
|||
72
App/lib/po-number.ts
Normal file
72
App/lib/po-number.ts
Normal 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 2025–Mar 2026
|
||||
*
|
||||
* Example: PMS/HNR1/200/2526
|
||||
*/
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
/** Indian financial year string. April–March 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] };
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -117,6 +117,7 @@ model Vessel {
|
|||
model Company {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String? @unique
|
||||
gstNumber String?
|
||||
address String?
|
||||
telephone String?
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue