feat: Companies — multi-company PO support with admin CRUD and export integration

Schema:
- New Company model (name, gstNumber, address, telephone, mobile, email, invoiceAddress, isActive)
- PurchaseOrder.companyId FK (optional, SET NULL on company delete)
- Migration: 20260530000003_add_company

Admin:
- /admin/companies page with full CRUD (create, edit, deactivate, delete)
- Companies table shows name, GST, contact details, status
- Companies link added to Admin section of sidebar (Briefcase icon)

PO forms (new / edit / import / manager-edit):
- Company dropdown appears at the top of Order Information when companies exist
- Pre-populated with first active company; selection persisted to DB via companyId

Import form:
- parseSheet() now extracts companyName from Excel row 1 (col A)
- Import preview auto-matches detected company name against known companies
- Shows detected name as a hint; user can override before saving

Export (PDF + XLSX):
- Company constants (CO_NAME, CO_ADDR, CO_TEL, INV_ADDR, INV_GST) are now
  derived from the linked Company record when present, falling back to the
  original Pelagia Marine hardcoded defaults when no company is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-30 19:31:34 +05:30
parent b43d44b59a
commit e308d86e93
22 changed files with 572 additions and 23 deletions

View file

@ -0,0 +1,97 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const companySchema = z.object({
name: z.string().min(1, "Company name is required"),
gstNumber: z.string().optional(),
address: z.string().optional(),
telephone: z.string().optional(),
mobile: z.string().optional(),
email: z.string().email("Invalid email").optional().or(z.literal("")),
invoiceAddress: z.string().optional(),
});
export async function createCompany(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const parsed = companySchema.safeParse({
name: formData.get("name"),
gstNumber: (formData.get("gstNumber") as string) || undefined,
address: (formData.get("address") as string) || undefined,
telephone: (formData.get("telephone") as string) || undefined,
mobile: (formData.get("mobile") as string) || undefined,
email: (formData.get("email") as string) || undefined,
invoiceAddress: (formData.get("invoiceAddress") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const { name, gstNumber, address, telephone, mobile, email, invoiceAddress } = parsed.data;
await db.company.create({
data: { name, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceAddress: invoiceAddress ?? null },
});
revalidatePath("/admin/companies");
return { ok: true };
}
export async function updateCompany(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const id = formData.get("id") as string;
if (!id) return { error: "Company ID is required" };
const parsed = companySchema.safeParse({
name: formData.get("name"),
gstNumber: (formData.get("gstNumber") as string) || undefined,
address: (formData.get("address") as string) || undefined,
telephone: (formData.get("telephone") as string) || undefined,
mobile: (formData.get("mobile") as string) || undefined,
email: (formData.get("email") as string) || undefined,
invoiceAddress: (formData.get("invoiceAddress") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const { name, gstNumber, address, telephone, mobile, email, invoiceAddress } = parsed.data;
await db.company.update({
where: { id },
data: { name, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceAddress: invoiceAddress ?? null },
});
revalidatePath("/admin/companies");
return { ok: true };
}
export async function deleteCompany(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" };
const inUse = await db.purchaseOrder.findFirst({ where: { companyId: id } });
if (inUse) return { error: "Cannot delete: company is referenced in purchase orders." };
await db.company.delete({ where: { id } });
revalidatePath("/admin/companies");
return { ok: true };
}
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const company = await db.company.findUnique({ where: { id }, select: { isActive: true } });
if (!company) return { error: "Company not found" };
await db.company.update({ where: { id }, data: { isActive: !company.isActive } });
revalidatePath("/admin/companies");
return { ok: true };
}

View file

@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { AddCompanyButton, EditCompanyButton } from "./company-form";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { deleteCompany, toggleCompanyActive } from "./actions";
export type CompanyRow = {
id: string;
name: string;
gstNumber: string | null;
address: string | null;
telephone: string | null;
mobile: string | null;
email: string | null;
invoiceAddress: string | null;
isActive: boolean;
};
function CompanyActionsMenu({ company }: { company: CompanyRow }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
return (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}>
{company.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditCompanyButton company={company} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog
open={deleteOpen} onOpenChange={setDeleteOpen}
label={company.name} onConfirm={() => deleteCompany(company.id)}
/>
<ConfirmDialog
open={toggleOpen} onOpenChange={setToggleOpen}
title={company.isActive ? `Deactivate ${company.name}?` : `Activate ${company.name}?`}
description={company.isActive ? `${company.name} will not appear in new PO selections.` : `${company.name} will become available for new POs.`}
confirmLabel={company.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleCompanyActive(company.id)}
/>
</>
);
}
export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1>
<p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p>
</div>
<AddCompanyButton />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Company Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">GST Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{companies.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-neutral-400">
No companies yet. Add one to start selecting it on purchase orders.
</td>
</tr>
)}
{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>
{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>
<td className="px-4 py-3 text-xs text-neutral-500 space-y-0.5">
{c.telephone && <p> {c.telephone}</p>}
{c.mobile && <p>📱 {c.mobile}</p>}
{c.email && <p> {c.email}</p>}
{!c.telephone && !c.mobile && !c.email && <span className="italic text-neutral-400"></span>}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${c.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{c.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<CompanyActionsMenu company={c} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createCompany, updateCompany } from "./actions";
type CompanyRow = {
id: string;
name: string;
gstNumber: string | null;
address: string | null;
telephone: string | null;
mobile: string | null;
email: string | null;
invoiceAddress: string | null;
isActive: boolean;
};
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
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>
<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>
<label className={LABEL}>Email</label>
<input name="email" type="email" defaultValue={company?.email ?? ""} className={INPUT} placeholder="accounts@company.com" />
</div>
<div>
<label className={LABEL}>Telephone</label>
<input name="telephone" defaultValue={company?.telephone ?? ""} className={INPUT} placeholder="+91-22-1234 5678" />
</div>
<div>
<label className={LABEL}>Mobile</label>
<input name="mobile" defaultValue={company?.mobile ?? ""} className={INPUT} placeholder="+91 98765 43210" />
</div>
</div>
<div>
<label className={LABEL}>Address</label>
<textarea name="address" defaultValue={company?.address ?? ""} rows={2} className={INPUT} placeholder="Office address" />
</div>
<div>
<label className={LABEL}>Invoice Address <span className="font-normal text-neutral-400">(shown on exported POs)</span></label>
<textarea name="invoiceAddress" defaultValue={company?.invoiceAddress ?? ""} rows={2} className={INPUT} placeholder="Full address as it should appear on invoices/POs" />
</div>
</div>
);
}
export function AddCompanyButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createCompany(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Company
</button>
<AdminDialog title="Add Company" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CompanyFormFields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Company"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditCompanyButton({
company,
open: controlledOpen,
onOpenChange,
}: {
company: CompanyRow;
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", company.id);
const result = await updateCompany(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
{!isControlled && (
<button onClick={() => setOpen(true)}
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
Edit
</button>
)}
<AdminDialog title={`Edit — ${company.name}`} open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CompanyFormFields company={company} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,34 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { CompaniesTable } from "./companies-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Company Management" };
export default async function AdminCompaniesPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const companies = await db.company.findMany({
orderBy: { name: "asc" },
});
return (
<CompaniesTable
companies={companies.map((c) => ({
id: c.id,
name: c.name,
gstNumber: c.gstNumber,
address: c.address,
telephone: c.telephone,
mobile: c.mobile,
email: c.email,
invoiceAddress: c.invoiceAddress,
isActive: c.isActive,
}))}
/>
);
}

View file

@ -7,7 +7,7 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
type SerializedLineItem = {
@ -38,6 +38,7 @@ interface Props {
vessels: VesselOption[];
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
}
const INPUT =
@ -50,7 +51,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
}
export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false);
@ -153,7 +154,18 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Order Information</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
{companies.length > 0 && (
<div>
<label className={LABEL}>Company <span className="text-danger">*</span></label>
<select name="companyId" required defaultValue={po.companyId ?? ""} className={INPUT}>
<option value="">Select company</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div className={companies.length > 0 ? "" : "sm:col-span-2"}>
<label className={LABEL}>Title <span className="text-danger">*</span></label>
<input name="title" required defaultValue={po.title} className={INPUT} />
</div>

View file

@ -45,6 +45,7 @@ export async function managerEditPo(
title: formData.get("title"),
vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"),
companyId: (formData.get("companyId") as string) || undefined,
projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined,
vendorId: formData.get("vendorId") || undefined,
@ -114,6 +115,7 @@ export async function managerEditPo(
title: data.title,
vesselId: data.vesselId,
accountId: data.accountId,
companyId: data.companyId ?? null,
vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null,
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,

View file

@ -6,6 +6,7 @@ import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
interface Props {
@ -28,7 +29,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
});
const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, leafAccounts, vendors] = await Promise.all([
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
db.purchaseOrder.findUnique({
where: { id },
include: {
@ -50,6 +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 } }),
]);
if (!po) notFound();
@ -95,6 +97,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
vessels={vessels}
accounts={accounts}
vendors={vendors}
companies={companies as CompanyOption[]}
/>
</div>

View file

@ -47,6 +47,7 @@ export async function updatePo(
title: formData.get("title"),
vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"),
companyId: (formData.get("companyId") as string) || undefined,
projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined,
vendorId: formData.get("vendorId") || undefined,
@ -137,6 +138,7 @@ export async function updatePo(
title: data.title,
vesselId: data.vesselId,
accountId: data.accountId,
companyId: data.companyId ?? null,
vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null,
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { updatePo } from "./actions";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { LineItemInput } from "@/lib/validations/po";
@ -39,10 +39,11 @@ interface Props {
vessels: VesselOption[];
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
managerNoteAuthor?: string | null;
}
export function EditPoForm({ po, vessels, accounts, vendors, managerNoteAuthor }: Props) {
export function EditPoForm({ po, vessels, accounts, vendors, companies, managerNoteAuthor }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({
@ -120,7 +121,20 @@ export function EditPoForm({ po, vessels, accounts, vendors, managerNoteAuthor }
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
{companies.length > 0 && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Company <span className="text-danger">*</span>
</label>
<select name="companyId" required defaultValue={po.companyId ?? ""} className={INPUT_CLS}>
<option value="">Select company</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div className={companies.length > 0 ? "" : "sm:col-span-2"}>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Title <span className="text-danger">*</span>
</label>

View file

@ -3,6 +3,7 @@ import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
interface Props {
@ -28,7 +29,7 @@ export default async function EditPoPage({ params }: Props) {
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
if (!canEdit) redirect(`/po/${id}`);
const [vessels, leafAccounts, vendors, noteAction] = await Promise.all([
const [vessels, leafAccounts, vendors, companies, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
@ -36,6 +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 } }),
po.status === "EDITS_REQUESTED"
? db.pOAction.findFirst({
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
@ -70,6 +72,7 @@ export default async function EditPoPage({ params }: Props) {
vessels={vessels}
accounts={accounts}
vendors={vendors}
companies={companies as CompanyOption[]}
managerNoteAuthor={noteAction?.actor.name ?? null}
/>
</div>

View file

@ -11,6 +11,7 @@ export type ImportPoInput = {
title: string;
vesselId: string;
accountId: string;
companyId?: string;
vendorId?: string;
piQuotationNo?: string;
placeOfDelivery?: string;
@ -46,6 +47,7 @@ export async function importPo(
currency: "INR",
vesselId: input.vesselId,
accountId: input.accountId,
companyId: input.companyId ?? null,
vendorId: input.vendorId ?? null,
piQuotationNo: input.piQuotationNo ?? null,
placeOfDelivery: input.placeOfDelivery ?? null,

View file

@ -3,7 +3,7 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import type { Vendor } from "@prisma/client";
import type { VesselOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { importPo } from "./actions";
import type { ParsedImport } from "@/app/api/po/import/route";
@ -16,6 +16,7 @@ interface Props {
vessels: VesselOption[];
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
}
type PreviewState = {
@ -24,9 +25,10 @@ type PreviewState = {
vesselId: string;
accountId: string;
vendorId: string;
companyId: string;
};
export function ImportForm({ vessels, accounts, vendors }: Props) {
export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [parsing, setParsing] = useState(false);
@ -61,6 +63,14 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
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;
setPreview({
parsed,
title: parsed.vendorName
@ -69,6 +79,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
vesselId: vessels[0]?.id ?? "",
accountId: accounts[0]?.items[0]?.id ?? "",
vendorId: matchedVendor?.id ?? "",
companyId: matchedCompany?.id ?? (companies[0]?.id ?? ""),
});
} catch {
setError("Network error while parsing file");
@ -86,6 +97,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
const result = await importPo({
title: preview.title,
vesselId: preview.vesselId,
companyId: preview.companyId || undefined,
accountId: preview.accountId,
vendorId: preview.vendorId || undefined,
piQuotationNo: preview.parsed.piQuotationNo || undefined,
@ -178,6 +190,29 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
className={INPUT_CLS}
/>
</div>
{companies.length > 0 && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Company <span className="text-danger">*</span>
{preview.parsed.companyName && (
<span className="ml-2 text-xs font-normal text-primary-600">
Detected: "{preview.parsed.companyName}"
</span>
)}
</label>
<select
value={preview.companyId}
onChange={(e) => setPreview({ ...preview, companyId: e.target.value })}
required
className={INPUT_CLS}
>
<option value="">Select company</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
@ -288,7 +323,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
</button>
<button
type="submit"
disabled={submitting || !preview.vesselId || !preview.accountId}
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"}

View file

@ -14,7 +14,7 @@ export default async function ImportPoPage() {
const { role } = session.user;
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
const [vessels, leafAccounts, vendors] = await Promise.all([
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
@ -22,6 +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 } }),
]);
const accounts = buildAccountGroups(leafAccounts);
@ -35,7 +36,7 @@ export default async function ImportPoPage() {
You then select the cost centre, accounting code, and confirm before saving as a draft.
</p>
</div>
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} />
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} companies={companies} />
</div>
);
}

View file

@ -53,6 +53,7 @@ export async function createPo(
title: formData.get("title"),
vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"),
companyId: (formData.get("companyId") as string) || undefined,
projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined,
vendorId: formData.get("vendorId") || undefined,
@ -90,6 +91,7 @@ export async function createPo(
currency: data.currency,
vesselId: data.vesselId,
accountId: data.accountId,
companyId: data.companyId ?? null,
vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null,
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,

View file

@ -13,6 +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 };
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";
@ -23,12 +24,14 @@ interface Props {
vessels: VesselOption[];
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
initialLineItems?: LineItemInput[];
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
}
export function NewPoForm({ vessels, accounts, vendors, initialLineItems, initialVendorId, initialVesselId }: Props) {
export function NewPoForm({ vessels, accounts, vendors, companies, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -81,7 +84,20 @@ export function NewPoForm({ vessels, accounts, vendors, initialLineItems, initia
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
{companies.length > 0 && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Company <span className="text-danger">*</span>
</label>
<select name="companyId" required defaultValue={initialCompanyId ?? ""} className={INPUT_CLS}>
<option value="">Select company</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div className={companies.length > 0 ? "" : "sm:col-span-2"}>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Title <span className="text-danger">*</span>
</label>

View file

@ -46,7 +46,7 @@ export default async function NewPoPage({ searchParams }: Props) {
}
}
const [vessels, leafAccounts, vendors] = await Promise.all([
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
@ -54,6 +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 } }),
]);
const accounts = buildAccountGroups(leafAccounts);
@ -70,6 +71,7 @@ export default async function NewPoPage({ searchParams }: Props) {
vessels={vessels}
accounts={accounts}
vendors={vendors}
companies={companies}
initialLineItems={initialLineItems}
initialVendorId={initialVendorId}
initialVesselId={initialVesselId}

View file

@ -5,13 +5,13 @@ import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { downloadBuffer } from "@/lib/storage";
// ── Company constants ─────────────────────────────────────────────────────────
// ── Company fallback constants (used when no company is linked to a PO) ──────
const CO_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
const CO_ADDR = "Office address: 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210";
const CO_TEL = "Tel: +91-22-6909 9028 / Email: technical@pelagiamarine.com / Mob: +91 74000 60772";
const INV_ADDR = "Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)";
const INV_GST = "Email: accounts@pelagiamarine.com GST NO: 27AAHCP5787B1Z6";
const DEFAULT_CO_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
const DEFAULT_CO_ADDR = "Office address: 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210";
const DEFAULT_CO_TEL = "Tel: +91-22-6909 9028 / Email: technical@pelagiamarine.com / Mob: +91 74000 60772";
const DEFAULT_INV_ADDR = "Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)";
const DEFAULT_INV_GST = "Email: accounts@pelagiamarine.com GST NO: 27AAHCP5787B1Z6";
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -35,7 +35,8 @@ export async function GET(request: NextRequest, { params }: Props) {
const po = await db.purchaseOrder.findUnique({
where: { id },
include: {
submitter: true, vessel: true, site: { select: { name: true } }, account: true,
submitter: true, vessel: true, account: true,
company: true,
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
lineItems: { orderBy: { sortOrder: "asc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
@ -60,6 +61,24 @@ export async function GET(request: NextRequest, { params }: Props) {
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
// ── Company data (from linked company, or fallback to constants) ──────────
const co = po.company;
const CO_NAME = co?.name ?? DEFAULT_CO_NAME;
const CO_ADDR = co?.address ? `Office address: ${co.address}` : DEFAULT_CO_ADDR;
const telParts = [
co?.telephone ? `Tel: ${co.telephone}` : null,
co?.email ? `Email: ${co.email}` : null,
co?.mobile ? `Mob: ${co.mobile}` : null,
].filter(Boolean);
const CO_TEL = telParts.length > 0 ? telParts.join(" / ") : DEFAULT_CO_TEL;
const INV_ADDR = co?.invoiceAddress ?? (co?.address ? `${co.name}, ${co.address}` : DEFAULT_INV_ADDR);
const INV_GST = [
co?.email ? `Email: ${co.email}` : null,
co?.gstNumber ? `GST NO: ${co.gstNumber}` : null,
].filter(Boolean).join(" ") || DEFAULT_INV_GST;
// ── Computed data ─────────────────────────────────────────────────────────
const items = po.lineItems.map((li, i) => {

View file

@ -14,6 +14,7 @@ import {
Users,
Ship,
Building2,
Briefcase,
Store,
Anchor,
Package,
@ -58,6 +59,7 @@ const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
];
export function Sidebar({ userRole }: { userRole: Role }) {

View file

@ -9,6 +9,7 @@ export type ParsedImportLine = {
};
export type ParsedImport = {
companyName: string;
poNumber: string;
piQuotationNo: string;
placeOfDelivery: string;
@ -40,6 +41,8 @@ export function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number
}
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 piQuotationNo = cellStr(sheet, 5, 2);
const placeOfDelivery = cellStr(sheet, 8, 2);
@ -88,6 +91,7 @@ export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
}
return {
companyName,
poNumber,
piQuotationNo,
placeOfDelivery,

View file

@ -31,6 +31,7 @@ export const createPoSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
vesselId: z.string().min(1, "Cost Centre is required"),
accountId: z.string().min(1, "Accounting Code is required"),
companyId: z.string().optional(),
projectCode: z.string().optional(),
dateRequired: z.string().optional(),
vendorId: z.string().optional(),

View file

@ -0,0 +1,21 @@
-- CreateTable: Company (sister companies for invoicing/accounting)
CREATE TABLE "Company" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"gstNumber" TEXT,
"address" TEXT,
"telephone" TEXT,
"mobile" TEXT,
"email" TEXT,
"invoiceAddress" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Company_pkey" PRIMARY KEY ("id")
);
-- AlterTable: add optional companyId FK to PurchaseOrder
ALTER TABLE "PurchaseOrder" ADD COLUMN "companyId" TEXT;
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -114,6 +114,22 @@ model Vessel {
purchaseOrders PurchaseOrder[]
}
model Company {
id String @id @default(cuid())
name String
gstNumber String?
address String?
telephone String?
mobile String?
email String?
invoiceAddress String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrders PurchaseOrder[]
}
model Account {
id String @id @default(cuid())
code String @unique
@ -257,6 +273,8 @@ model PurchaseOrder {
vessel Vessel @relation(fields: [vesselId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
companyId String?
company Company? @relation(fields: [companyId], references: [id])
vendorId String?
vendor Vendor? @relation(fields: [vendorId], references: [id])
siteId String?