diff --git a/App/app/(portal)/admin/companies/company-form.tsx b/App/app/(portal)/admin/companies/company-form.tsx
index 293548d..467f7c5 100644
--- a/App/app/(portal)/admin/companies/company-form.tsx
+++ b/App/app/(portal)/admin/companies/company-form.tsx
@@ -2,11 +2,12 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { AdminDialog } from "@/components/ui/admin-dialog";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
import { createCompany, updateCompany } from "./actions";
import { CompanyBrandingUploader } from "./company-branding-uploader";
-type CompanyRow = {
+export type CompanyFormData = {
id: string;
name: string;
code: string | null;
@@ -25,7 +26,7 @@ type CompanyRow = {
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 }) {
+function CompanyFormFields({ company }: { company?: CompanyFormData }) {
return (
@@ -70,117 +71,83 @@ function CompanyFormFields({ company }: { company?: CompanyRow }) {
+
+ );
+}
- {/* ── Branding (shown on exported POs) ── */}
-
-
Branding (shown on exported POs)
- {company?.id ? (
-
+export function CompanyForm({ company }: { company?: CompanyFormData }) {
+ const router = useRouter();
+ const isEdit = !!company?.id;
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+
+ async function handleSubmit(e: React.FormEvent
) {
+ e.preventDefault();
+ setPending(true);
+ setError("");
+ const fd = new FormData(e.currentTarget);
+
+ if (isEdit) {
+ fd.set("id", company!.id);
+ const result = await updateCompany(fd);
+ if ("error" in result) { setError(result.error); setPending(false); return; }
+ router.push("/admin/companies");
+ router.refresh();
+ } else {
+ const result = await createCompany(fd);
+ if ("error" in result) { setError(result.error); setPending(false); return; }
+ // Land on the edit page so the logo/stamp can be uploaded against the new company.
+ router.push(`/admin/companies/${result.id}/edit`);
+ router.refresh();
+ }
+ }
+
+ return (
+
+
+
Back to Companies
+
+
{isEdit ? `Edit — ${company!.name}` : "Add Company"}
+
Sister company used for invoicing and purchase orders
+
+
+
+ {/* ── Branding (independent uploads; available once the company exists) ── */}
+
+
Branding
+
Logo and stamp shown on exported POs
+ {isEdit ? (
+
) : (
-
Save the company first, then upload a logo and stamp from Edit.
+
Create the company first — you'll be taken to the edit page where you can upload a logo and stamp.
)}
);
}
-
-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) {
- 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 (
- <>
-
- setOpen(false)}>
-
-
- >
- );
-}
-
-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) {
- 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 && (
-
- )}
- setOpen(false)}>
-
-
- >
- );
-}
diff --git a/App/app/(portal)/admin/companies/new/page.tsx b/App/app/(portal)/admin/companies/new/page.tsx
new file mode 100644
index 0000000..88f9c08
--- /dev/null
+++ b/App/app/(portal)/admin/companies/new/page.tsx
@@ -0,0 +1,15 @@
+import { auth } from "@/auth";
+import { hasPermission } from "@/lib/permissions";
+import { redirect } from "next/navigation";
+import { CompanyForm } from "../company-form";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = { title: "Add Company" };
+
+export default async function NewCompanyPage() {
+ const session = await auth();
+ if (!session?.user) redirect("/login");
+ if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
+
+ return ;
+}
diff --git a/App/app/(portal)/admin/companies/page.tsx b/App/app/(portal)/admin/companies/page.tsx
index 9177291..049eb57 100644
--- a/App/app/(portal)/admin/companies/page.tsx
+++ b/App/app/(portal)/admin/companies/page.tsx
@@ -1,7 +1,6 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
-import { generateDownloadUrl } from "@/lib/storage";
import { redirect } from "next/navigation";
import { CompaniesTable } from "./companies-table";
import type { Metadata } from "next";
@@ -17,23 +16,21 @@ export default async function AdminCompaniesPage() {
orderBy: { name: "asc" },
});
- const rows = await Promise.all(
- companies.map(async (c) => ({
- id: c.id,
- name: c.name,
- code: c.code,
- gstNumber: c.gstNumber,
- address: c.address,
- telephone: c.telephone,
- mobile: c.mobile,
- email: c.email,
- invoiceEmail: c.invoiceEmail,
- invoiceAddress: c.invoiceAddress,
- logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null,
- stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null,
- isActive: c.isActive,
- }))
+ return (
+ ({
+ id: c.id,
+ name: c.name,
+ code: c.code,
+ gstNumber: c.gstNumber,
+ address: c.address,
+ telephone: c.telephone,
+ mobile: c.mobile,
+ email: c.email,
+ invoiceEmail: c.invoiceEmail,
+ invoiceAddress: c.invoiceAddress,
+ isActive: c.isActive,
+ }))}
+ />
);
-
- return ;
}
diff --git a/App/tests/integration/company-crud.test.ts b/App/tests/integration/company-crud.test.ts
new file mode 100644
index 0000000..423e983
--- /dev/null
+++ b/App/tests/integration/company-crud.test.ts
@@ -0,0 +1,84 @@
+/**
+ * Integration tests for company create/update actions.
+ * Focus on the behaviour the dedicated add/edit pages rely on:
+ * - createCompany returns the new id (so the create flow can redirect to the edit page)
+ * - fields persist, code is upper-cased, duplicate codes are rejected
+ * - updateCompany edits in place
+ * - both actions are gated by manage_vessels_accounts
+ */
+import { vi, describe, it, expect, afterAll } from "vitest";
+
+vi.mock("@/auth", () => ({ auth: vi.fn() }));
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { createCompany, updateCompany } from "@/app/(portal)/admin/companies/actions";
+import { makeSession, fd } from "./helpers";
+
+const mockedAuth = vi.mocked(auth);
+const NAME_PREFIX = "INTTEST_CRUD_";
+
+afterAll(async () => {
+ await db.company.deleteMany({ where: { name: { startsWith: NAME_PREFIX } } });
+});
+
+describe("createCompany", () => {
+ it("returns the new id and persists the company (code upper-cased)", async () => {
+ mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
+ const result = await createCompany(fd({
+ name: `${NAME_PREFIX}Alpha`,
+ code: "zzcrudA",
+ gstNumber: "27AAHCP5787B1Z6",
+ }));
+
+ expect("id" in result && result.ok).toBe(true);
+ if (!("id" in result)) throw new Error(result.error);
+
+ const c = await db.company.findUniqueOrThrow({ where: { id: result.id } });
+ expect(c.name).toBe(`${NAME_PREFIX}Alpha`);
+ expect(c.code).toBe("ZZCRUDA");
+ expect(c.gstNumber).toBe("27AAHCP5787B1Z6");
+ });
+
+ it("rejects a duplicate code (case-insensitive)", async () => {
+ mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
+ const first = await createCompany(fd({ name: `${NAME_PREFIX}Dup1`, code: "zzcrudd" }));
+ expect("id" in first).toBe(true);
+
+ const second = await createCompany(fd({ name: `${NAME_PREFIX}Dup2`, code: "ZZCRUDD" }));
+ expect("error" in second).toBe(true);
+ });
+
+ it("refuses callers without manage_vessels_accounts", async () => {
+ mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
+ const result = await createCompany(fd({ name: `${NAME_PREFIX}Nope`, code: "zzcrudN" }));
+ expect(result).toEqual({ error: "Unauthorized" });
+ });
+});
+
+describe("updateCompany", () => {
+ it("edits an existing company in place", async () => {
+ mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
+ const created = await createCompany(fd({ name: `${NAME_PREFIX}Edit`, code: "zzcrudE" }));
+ if (!("id" in created)) throw new Error(created.error);
+
+ const result = await updateCompany(fd({
+ id: created.id,
+ name: `${NAME_PREFIX}Edited`,
+ code: "zzcrudE",
+ mobile: "+91 99999 00000",
+ }));
+ expect(result).toEqual({ ok: true });
+
+ const c = await db.company.findUniqueOrThrow({ where: { id: created.id } });
+ expect(c.name).toBe(`${NAME_PREFIX}Edited`);
+ expect(c.mobile).toBe("+91 99999 00000");
+ });
+
+ it("refuses callers without manage_vessels_accounts", async () => {
+ mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
+ const result = await updateCompany(fd({ id: "whatever", name: "x", code: "ZZX" }));
+ expect(result).toEqual({ error: "Unauthorized" });
+ });
+});