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 { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
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";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
|
||||||
type SerializedLineItem = {
|
type SerializedLineItem = {
|
||||||
|
|
@ -38,6 +38,7 @@ interface Props {
|
||||||
vessels: VesselOption[];
|
vessels: VesselOption[];
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
companies: CompanyOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT =
|
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 />;
|
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 router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -153,7 +154,18 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Order Information</h3>
|
<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="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>
|
<label className={LABEL}>Title <span className="text-danger">*</span></label>
|
||||||
<input name="title" required defaultValue={po.title} className={INPUT} />
|
<input name="title" required defaultValue={po.title} className={INPUT} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export async function managerEditPo(
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
vesselId: formData.get("vesselId"),
|
vesselId: formData.get("vesselId"),
|
||||||
accountId: formData.get("accountId"),
|
accountId: formData.get("accountId"),
|
||||||
|
companyId: (formData.get("companyId") as string) || undefined,
|
||||||
projectCode: formData.get("projectCode") || undefined,
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
dateRequired: formData.get("dateRequired") || undefined,
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
vendorId: formData.get("vendorId") || undefined,
|
vendorId: formData.get("vendorId") || undefined,
|
||||||
|
|
@ -114,6 +115,7 @@ export async function managerEditPo(
|
||||||
title: data.title,
|
title: data.title,
|
||||||
vesselId: data.vesselId,
|
vesselId: data.vesselId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
|
companyId: data.companyId ?? null,
|
||||||
vendorId: data.vendorId ?? null,
|
vendorId: data.vendorId ?? null,
|
||||||
projectCode: data.projectCode ?? null,
|
projectCode: data.projectCode ?? null,
|
||||||
dateRequired: data.dateRequired ? new Date(data.dateRequired) : 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 { PoDetail } from "@/components/po/po-detail";
|
||||||
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -28,7 +29,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
const hasSignature = !!(currentUser?.signatureKey);
|
||||||
|
|
||||||
const [po, vessels, leafAccounts, vendors] = await Promise.all([
|
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
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 } } } } },
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
|
|
@ -95,6 +97,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
vessels={vessels}
|
vessels={vessels}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
companies={companies as CompanyOption[]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export async function updatePo(
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
vesselId: formData.get("vesselId"),
|
vesselId: formData.get("vesselId"),
|
||||||
accountId: formData.get("accountId"),
|
accountId: formData.get("accountId"),
|
||||||
|
companyId: (formData.get("companyId") as string) || undefined,
|
||||||
projectCode: formData.get("projectCode") || undefined,
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
dateRequired: formData.get("dateRequired") || undefined,
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
vendorId: formData.get("vendorId") || undefined,
|
vendorId: formData.get("vendorId") || undefined,
|
||||||
|
|
@ -137,6 +138,7 @@ export async function updatePo(
|
||||||
title: data.title,
|
title: data.title,
|
||||||
vesselId: data.vesselId,
|
vesselId: data.vesselId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
|
companyId: data.companyId ?? null,
|
||||||
vendorId: data.vendorId ?? null,
|
vendorId: data.vendorId ?? null,
|
||||||
projectCode: data.projectCode ?? null,
|
projectCode: data.projectCode ?? null,
|
||||||
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
|
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { updatePo } from "./actions";
|
import { updatePo } from "./actions";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
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 { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
@ -39,10 +39,11 @@ interface Props {
|
||||||
vessels: VesselOption[];
|
vessels: VesselOption[];
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
companies: CompanyOption[];
|
||||||
managerNoteAuthor?: string | null;
|
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 router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
po.lineItems.map((li) => ({
|
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">
|
<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>
|
<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="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">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Title <span className="text-danger">*</span>
|
Title <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { EditPoForm } from "./edit-po-form";
|
import { EditPoForm } from "./edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -28,7 +29,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
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.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
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 } } } } },
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
po.status === "EDITS_REQUESTED"
|
po.status === "EDITS_REQUESTED"
|
||||||
? db.pOAction.findFirst({
|
? db.pOAction.findFirst({
|
||||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
||||||
|
|
@ -70,6 +72,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
vessels={vessels}
|
vessels={vessels}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
companies={companies as CompanyOption[]}
|
||||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export type ImportPoInput = {
|
||||||
title: string;
|
title: string;
|
||||||
vesselId: string;
|
vesselId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
companyId?: string;
|
||||||
vendorId?: string;
|
vendorId?: string;
|
||||||
piQuotationNo?: string;
|
piQuotationNo?: string;
|
||||||
placeOfDelivery?: string;
|
placeOfDelivery?: string;
|
||||||
|
|
@ -46,6 +47,7 @@ export async function importPo(
|
||||||
currency: "INR",
|
currency: "INR",
|
||||||
vesselId: input.vesselId,
|
vesselId: input.vesselId,
|
||||||
accountId: input.accountId,
|
accountId: input.accountId,
|
||||||
|
companyId: input.companyId ?? null,
|
||||||
vendorId: input.vendorId ?? null,
|
vendorId: input.vendorId ?? null,
|
||||||
piQuotationNo: input.piQuotationNo ?? null,
|
piQuotationNo: input.piQuotationNo ?? null,
|
||||||
placeOfDelivery: input.placeOfDelivery ?? null,
|
placeOfDelivery: input.placeOfDelivery ?? null,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Vendor } from "@prisma/client";
|
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 { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { importPo } from "./actions";
|
import { importPo } from "./actions";
|
||||||
import type { ParsedImport } from "@/app/api/po/import/route";
|
import type { ParsedImport } from "@/app/api/po/import/route";
|
||||||
|
|
@ -16,6 +16,7 @@ interface Props {
|
||||||
vessels: VesselOption[];
|
vessels: VesselOption[];
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
companies: CompanyOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
|
|
@ -24,9 +25,10 @@ type PreviewState = {
|
||||||
vesselId: string;
|
vesselId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
|
companyId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImportForm({ vessels, accounts, vendors }: Props) {
|
export function ImportForm({ vessels, accounts, vendors, companies }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [parsing, setParsing] = useState(false);
|
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))
|
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({
|
setPreview({
|
||||||
parsed,
|
parsed,
|
||||||
title: parsed.vendorName
|
title: parsed.vendorName
|
||||||
|
|
@ -69,6 +79,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
vesselId: vessels[0]?.id ?? "",
|
vesselId: vessels[0]?.id ?? "",
|
||||||
accountId: accounts[0]?.items[0]?.id ?? "",
|
accountId: accounts[0]?.items[0]?.id ?? "",
|
||||||
vendorId: matchedVendor?.id ?? "",
|
vendorId: matchedVendor?.id ?? "",
|
||||||
|
companyId: matchedCompany?.id ?? (companies[0]?.id ?? ""),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error while parsing file");
|
setError("Network error while parsing file");
|
||||||
|
|
@ -86,6 +97,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
const result = await importPo({
|
const result = await importPo({
|
||||||
title: preview.title,
|
title: preview.title,
|
||||||
vesselId: preview.vesselId,
|
vesselId: preview.vesselId,
|
||||||
|
companyId: preview.companyId || undefined,
|
||||||
accountId: preview.accountId,
|
accountId: preview.accountId,
|
||||||
vendorId: preview.vendorId || undefined,
|
vendorId: preview.vendorId || undefined,
|
||||||
piQuotationNo: preview.parsed.piQuotationNo || undefined,
|
piQuotationNo: preview.parsed.piQuotationNo || undefined,
|
||||||
|
|
@ -178,6 +190,29 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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 ? "Creating…" : "Create as Draft"}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default async function ImportPoPage() {
|
||||||
const { role } = session.user;
|
const { role } = session.user;
|
||||||
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
|
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.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
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 } } } } },
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ orderBy: { name: "asc" } }),
|
db.vendor.findMany({ orderBy: { name: "asc" } }),
|
||||||
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
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.
|
You then select the cost centre, accounting code, and confirm before saving as a draft.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} />
|
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} companies={companies} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export async function createPo(
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
vesselId: formData.get("vesselId"),
|
vesselId: formData.get("vesselId"),
|
||||||
accountId: formData.get("accountId"),
|
accountId: formData.get("accountId"),
|
||||||
|
companyId: (formData.get("companyId") as string) || undefined,
|
||||||
projectCode: formData.get("projectCode") || undefined,
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
dateRequired: formData.get("dateRequired") || undefined,
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
vendorId: formData.get("vendorId") || undefined,
|
vendorId: formData.get("vendorId") || undefined,
|
||||||
|
|
@ -90,6 +91,7 @@ export async function createPo(
|
||||||
currency: data.currency,
|
currency: data.currency,
|
||||||
vesselId: data.vesselId,
|
vesselId: data.vesselId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
|
companyId: data.companyId ?? null,
|
||||||
vendorId: data.vendorId ?? null,
|
vendorId: data.vendorId ?? null,
|
||||||
projectCode: data.projectCode ?? null,
|
projectCode: data.projectCode ?? null,
|
||||||
dateRequired: data.dateRequired ? new Date(data.dateRequired) : 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 VesselOption = { id: string; code: string; name: string };
|
||||||
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
|
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
|
||||||
|
export type CompanyOption = { id: string; name: string };
|
||||||
|
|
||||||
const INPUT_CLS =
|
const INPUT_CLS =
|
||||||
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
@ -23,12 +24,14 @@ interface Props {
|
||||||
vessels: VesselOption[];
|
vessels: VesselOption[];
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
companies: CompanyOption[];
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
initialVendorId?: string;
|
initialVendorId?: string;
|
||||||
initialVesselId?: 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 router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
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">
|
<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>
|
<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="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">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Title <span className="text-danger">*</span>
|
Title <span className="text-danger">*</span>
|
||||||
</label>
|
</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.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
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 } } } } },
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
|
@ -70,6 +71,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
vessels={vessels}
|
vessels={vessels}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
companies={companies}
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={initialLineItems}
|
||||||
initialVendorId={initialVendorId}
|
initialVendorId={initialVendorId}
|
||||||
initialVesselId={initialVesselId}
|
initialVesselId={initialVesselId}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ import ExcelJS from "exceljs";
|
||||||
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||||
import { downloadBuffer } from "@/lib/storage";
|
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 DEFAULT_CO_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
|
||||||
const CO_ADDR = "Office address: 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210";
|
const DEFAULT_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 DEFAULT_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 DEFAULT_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_INV_GST = "Email: accounts@pelagiamarine.com GST NO: 27AAHCP5787B1Z6";
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -35,7 +35,8 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
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 } } },
|
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "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";
|
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 ─────────────────────────────────────────────────────────
|
// ── Computed data ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const items = po.lineItems.map((li, i) => {
|
const items = po.lineItems.map((li, i) => {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Ship,
|
Ship,
|
||||||
Building2,
|
Building2,
|
||||||
|
Briefcase,
|
||||||
Store,
|
Store,
|
||||||
Anchor,
|
Anchor,
|
||||||
Package,
|
Package,
|
||||||
|
|
@ -58,6 +59,7 @@ const ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/users", label: "Users", icon: Users },
|
{ href: "/admin/users", label: "Users", icon: Users },
|
||||||
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
||||||
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
||||||
|
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ userRole }: { userRole: Role }) {
|
export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type ParsedImportLine = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedImport = {
|
export type ParsedImport = {
|
||||||
|
companyName: string;
|
||||||
poNumber: string;
|
poNumber: string;
|
||||||
piQuotationNo: string;
|
piQuotationNo: string;
|
||||||
placeOfDelivery: 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 {
|
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 poNumber = cellStr(sheet, 4, 2);
|
||||||
const piQuotationNo = cellStr(sheet, 5, 2);
|
const piQuotationNo = cellStr(sheet, 5, 2);
|
||||||
const placeOfDelivery = cellStr(sheet, 8, 2);
|
const placeOfDelivery = cellStr(sheet, 8, 2);
|
||||||
|
|
@ -88,6 +91,7 @@ export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
companyName,
|
||||||
poNumber,
|
poNumber,
|
||||||
piQuotationNo,
|
piQuotationNo,
|
||||||
placeOfDelivery,
|
placeOfDelivery,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const createPoSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required").max(200),
|
title: z.string().min(1, "Title is required").max(200),
|
||||||
vesselId: z.string().min(1, "Cost Centre is required"),
|
vesselId: z.string().min(1, "Cost Centre is required"),
|
||||||
accountId: z.string().min(1, "Accounting Code is required"),
|
accountId: z.string().min(1, "Accounting Code is required"),
|
||||||
|
companyId: z.string().optional(),
|
||||||
projectCode: z.string().optional(),
|
projectCode: z.string().optional(),
|
||||||
dateRequired: z.string().optional(),
|
dateRequired: z.string().optional(),
|
||||||
vendorId: 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[]
|
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 {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
|
|
@ -257,6 +273,8 @@ model PurchaseOrder {
|
||||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||||||
accountId String
|
accountId String
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
companyId String?
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
vendorId String?
|
vendorId String?
|
||||||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||||
siteId String?
|
siteId String?
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue