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({
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 “Create as Draft”.
|
{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 “{parsed.vendorName}” — no match found. Assign or add from Vendor Registry.
|
✓ Vendor “{parsed.vendorName}” 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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
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 = {
|
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
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 {
|
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?
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue