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:
parent
b43d44b59a
commit
e308d86e93
22 changed files with 572 additions and 23 deletions
97
App/app/(portal)/admin/companies/actions.ts
Normal file
97
App/app/(portal)/admin/companies/actions.ts
Normal 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 };
|
||||
}
|
||||
111
App/app/(portal)/admin/companies/companies-table.tsx
Normal file
111
App/app/(portal)/admin/companies/companies-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
App/app/(portal)/admin/companies/company-form.tsx
Normal file
148
App/app/(portal)/admin/companies/company-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
App/app/(portal)/admin/companies/page.tsx
Normal file
34
App/app/(portal)/admin/companies/page.tsx
Normal 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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue