From e308d86e93cc16bd01209c3a93cd4cc3481f94e2 Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 30 May 2026 19:31:34 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Companies=20=E2=80=94=20multi-company?= =?UTF-8?q?=20PO=20support=20with=20admin=20CRUD=20and=20export=20integrat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/app/(portal)/admin/companies/actions.ts | 97 ++++++++++++ .../admin/companies/companies-table.tsx | 111 +++++++++++++ .../(portal)/admin/companies/company-form.tsx | 148 ++++++++++++++++++ App/app/(portal)/admin/companies/page.tsx | 34 ++++ .../approvals/[id]/manager-edit-po-form.tsx | 18 ++- .../approvals/[id]/manager-po-edit-actions.ts | 2 + App/app/(portal)/approvals/[id]/page.tsx | 5 +- App/app/(portal)/po/[id]/edit/actions.ts | 2 + .../(portal)/po/[id]/edit/edit-po-form.tsx | 20 ++- App/app/(portal)/po/[id]/edit/page.tsx | 5 +- App/app/(portal)/po/import/actions.ts | 2 + App/app/(portal)/po/import/import-form.tsx | 41 ++++- App/app/(portal)/po/import/page.tsx | 5 +- App/app/(portal)/po/new/actions.ts | 2 + App/app/(portal)/po/new/new-po-form.tsx | 20 ++- App/app/(portal)/po/new/page.tsx | 4 +- App/app/api/po/[id]/export/route.ts | 33 +++- App/components/layout/sidebar.tsx | 2 + App/lib/po-import-parser.ts | 4 + App/lib/validations/po.ts | 1 + .../20260530000003_add_company/migration.sql | 21 +++ App/prisma/schema.prisma | 18 +++ 22 files changed, 572 insertions(+), 23 deletions(-) create mode 100644 App/app/(portal)/admin/companies/actions.ts create mode 100644 App/app/(portal)/admin/companies/companies-table.tsx create mode 100644 App/app/(portal)/admin/companies/company-form.tsx create mode 100644 App/app/(portal)/admin/companies/page.tsx create mode 100644 App/prisma/migrations/20260530000003_add_company/migration.sql diff --git a/App/app/(portal)/admin/companies/actions.ts b/App/app/(portal)/admin/companies/actions.ts new file mode 100644 index 0000000..bb05530 --- /dev/null +++ b/App/app/(portal)/admin/companies/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/admin/companies/companies-table.tsx b/App/app/(portal)/admin/companies/companies-table.tsx new file mode 100644 index 0000000..5094dab --- /dev/null +++ b/App/app/(portal)/admin/companies/companies-table.tsx @@ -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 ( + <> + + setEditOpen(true)}>Edit + setToggleOpen(true)}> + {company.isActive ? "Deactivate" : "Activate"} + + + setDeleteOpen(true)}>Delete + + + deleteCompany(company.id)} + /> + toggleCompanyActive(company.id)} + /> + + ); +} + +export function CompaniesTable({ companies }: { companies: CompanyRow[] }) { + return ( +
+
+
+

Company Management

+

Sister companies used for invoicing and purchase orders

+
+ +
+ +
+ + + + + + + + + + + + {companies.length === 0 && ( + + + + )} + {companies.map((c) => ( + + + + + + + + ))} + +
Company NameGST NumberContactStatus
+ No companies yet. Add one to start selecting it on purchase orders. +
+

{c.name}

+ {c.address &&

{c.address}

} +
{c.gstNumber ?? β€”} + {c.telephone &&

☎ {c.telephone}

} + {c.mobile &&

πŸ“± {c.mobile}

} + {c.email &&

βœ‰ {c.email}

} + {!c.telephone && !c.mobile && !c.email && β€”} +
+ + {c.isActive ? "Active" : "Inactive"} + + + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/companies/company-form.tsx b/App/app/(portal)/admin/companies/company-form.tsx new file mode 100644 index 0000000..c0ac4a0 --- /dev/null +++ b/App/app/(portal)/admin/companies/company-form.tsx @@ -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 ( +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +