diff --git a/App/app/(portal)/admin/companies/actions.ts b/App/app/(portal)/admin/companies/actions.ts index 8099dc3..f1c81cf 100644 --- a/App/app/(portal)/admin/companies/actions.ts +++ b/App/app/(portal)/admin/companies/actions.ts @@ -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 { 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 { }); 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 { 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 { }); 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 }; diff --git a/App/app/(portal)/admin/companies/companies-table.tsx b/App/app/(portal)/admin/companies/companies-table.tsx index 9a06164..b0516fa 100644 --- a/App/app/(portal)/admin/companies/companies-table.tsx +++ b/App/app/(portal)/admin/companies/companies-table.tsx @@ -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) => ( -

{c.name}

+
+ {c.code && ( + {c.code} + )} +

{c.name}

+
{c.address &&

{c.address}

} {c.gstNumber ?? } diff --git a/App/app/(portal)/admin/companies/company-form.tsx b/App/app/(portal)/admin/companies/company-form.tsx index 3be7f95..ba44318 100644 --- a/App/app/(portal)/admin/companies/company-form.tsx +++ b/App/app/(portal)/admin/companies/company-form.tsx @@ -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 (
-
- - +
+
+ + +
+
+ + +
-
+
diff --git a/App/app/(portal)/admin/companies/page.tsx b/App/app/(portal)/admin/companies/page.tsx index 72e29ba..049eb57 100644 --- a/App/app/(portal)/admin/companies/page.tsx +++ b/App/app/(portal)/admin/companies/page.tsx @@ -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, diff --git a/App/app/(portal)/admin/sites/[id]/page.tsx b/App/app/(portal)/admin/sites/[id]/page.tsx index fa9b01e..a80239c 100644 --- a/App/app/(portal)/admin/sites/[id]/page.tsx +++ b/App/app/(portal)/admin/sites/[id]/page.tsx @@ -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) {
- {/* Charts */} - {(inventoryChartData.length > 0 || consumptionChartData.length > 0) && ( - + {/* Inventory tracking — hidden when NEXT_PUBLIC_INVENTORY_ENABLED=false */} + {INVENTORY_ENABLED && ( + <> + {/* Charts */} + {(inventoryChartData.length > 0 || consumptionChartData.length > 0) && ( + + )} + + {/* Inventory table */} +
+

Inventory at this site

+ {site.inventory.length === 0 ? ( +

No inventory tracked yet.

+ ) : ( + + + + + + + + + + + {site.inventory.map((inv) => ( + + + + + + + ))} + +
ItemCodeQty on handUpdated
+ + {inv.product.name} + + {inv.product.code}{Number(inv.quantity)}{formatDate(inv.updatedAt)}
+ )} +
+ + {/* Record consumption */} +
+

Record Daily Consumption

+ +
+ )} - {/* Inventory table */} -
-

Inventory at this site

- {site.inventory.length === 0 ? ( -

No inventory tracked yet. Updated automatically when POs are delivered here.

- ) : ( - - - - - - - - - - - {site.inventory.map((inv) => ( - - - - - - - ))} - -
ItemCodeQty on handUpdated
- - {inv.product.name} - - {inv.product.code}{Number(inv.quantity)}{formatDate(inv.updatedAt)}
- )} -
- - {/* Record consumption */} -
-

Record Daily Consumption

- -
- {/* Recent POs */} {site.purchaseOrders.length > 0 && ( diff --git a/App/app/(portal)/approvals/[id]/page.tsx b/App/app/(portal)/approvals/[id]/page.tsx index 951f157..376c641 100644 --- a/App/app/(portal)/approvals/[id]/page.tsx +++ b/App/app/(portal)/approvals/[id]/page.tsx @@ -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} />
diff --git a/App/app/(portal)/po/[id]/edit/page.tsx b/App/app/(portal)/po/[id]/edit/page.tsx index 40be898..d6db2b9 100644 --- a/App/app/(portal)/po/[id]/edit/page.tsx +++ b/App/app/(portal)/po/[id]/edit/page.tsx @@ -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} />
diff --git a/App/app/(portal)/po/import/actions.ts b/App/app/(portal)/po/import/actions.ts index d600308..65a0c9c 100644 --- a/App/app/(portal)/po/import/actions.ts +++ b/App/app/(portal)/po/import/actions.ts @@ -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 }; } diff --git a/App/app/(portal)/po/import/import-form.tsx b/App/app/(portal)/po/import/import-form.tsx index 0a8ec06..95702e7 100644 --- a/App/app/(portal)/po/import/import-form.tsx +++ b/App/app/(portal)/po/import/import-form.tsx @@ -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 (
{/* Extracted data banner */} -
- Parsed from file.{" "} - {parsed.vendorName && <>Vendor: {parsed.vendorName}. } - {parsed.piQuotationNo && <>Quotation: {parsed.piQuotationNo}. } - Review and fill in the fields below, then click “Create as Draft”. +
+
+ Parsed from file.{" "} + {parsed.vendorName && <>Vendor: {parsed.vendorName}. } + {parsed.piQuotationNo && <>Quotation: {parsed.piQuotationNo}. } + This PO will be saved directly as Closed — no approval needed. +
+ {parsed.poNumber && ( +
+ PO Number: {parsed.poNumber} + {parsed.companyCode && <> · Company: {parsed.companyCode}} + {parsed.costCentreCode && <> · Cost Centre: {parsed.costCentreCode}} + {parsed.poSequenceId !== null && <> · ID: {parsed.poSequenceId}} +
+ )}
{/* User-required fields */} @@ -259,8 +286,8 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) { ))} {parsed.vendorName && !preview.vendorId && ( -

- Extracted vendor “{parsed.vendorName}” — no match found. Assign or add from Vendor Registry. +

+ ✓ Vendor “{parsed.vendorName}” will be auto-created on submit.

)}
@@ -268,9 +295,14 @@ export function ImportForm({ vessels, accounts, vendors, companies }: Props) { {/* Line items preview */}
-

- Line Items ({parsed.lineItems.length} items) -

+
+

+ Line Items ({parsed.lineItems.length} items) +

+

+ ✓ Products will be auto-created in the catalogue on submit +

+
@@ -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"} diff --git a/App/app/(portal)/po/import/page.tsx b/App/app/(portal)/po/import/page.tsx index 8a34ad0..d5c7490 100644 --- a/App/app/(portal)/po/import/page.tsx +++ b/App/app/(portal)/po/import/page.tsx @@ -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() {

Import Purchase Order

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.

diff --git a/App/app/(portal)/po/new/actions.ts b/App/app/(portal)/po/new/actions.ts index 17d8201..38be188 100644 --- a/App/app/(portal)/po/new/actions.ts +++ b/App/app/(portal)/po/new/actions.ts @@ -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, diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index 574f9dc..26cc335 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -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"; diff --git a/App/app/(portal)/po/new/page.tsx b/App/app/(portal)/po/new/page.tsx index f018495..4acf2b2 100644 --- a/App/app/(portal)/po/new/page.tsx +++ b/App/app/(portal)/po/new/page.tsx @@ -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); diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index fefec4a..d924e4d 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -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 }, diff --git a/App/lib/feature-flags.ts b/App/lib/feature-flags.ts new file mode 100644 index 0000000..3b662a2 --- /dev/null +++ b/App/lib/feature-flags.ts @@ -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"; diff --git a/App/lib/po-import-parser.ts b/App/lib/po-import-parser.ts index a7f9d3e..b107f18 100644 --- a/App/lib/po-import-parser.ts +++ b/App/lib/po-import-parser.ts @@ -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, diff --git a/App/lib/po-number.ts b/App/lib/po-number.ts new file mode 100644 index 0000000..6a6c120 --- /dev/null +++ b/App/lib/po-number.ts @@ -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 { + 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 { + 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] }; +} diff --git a/App/prisma/migrations/20260531000001_company_code/migration.sql b/App/prisma/migrations/20260531000001_company_code/migration.sql new file mode 100644 index 0000000..2474dcb --- /dev/null +++ b/App/prisma/migrations/20260531000001_company_code/migration.sql @@ -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"); diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 8cbf5ef..9a9b9e1 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -117,6 +117,7 @@ model Vessel { model Company { id String @id @default(cuid()) name String + code String? @unique gstNumber String? address String? telephone String?