feat(companies): move add/edit from dialog to dedicated pages
Some checks failed
PR checks / checks (pull_request) Failing after 3s

The company form outgrew the modal once the branding (logo/stamp) section
was added. Add/edit now live on their own routes:
- /admin/companies/new
- /admin/companies/[id]/edit

- createCompany returns the new id and the create flow lands on the edit
  page so logo/stamp can be uploaded immediately
- list "+ Add Company" is a link; row "Edit" navigates to the edit page
- branding is its own card on the edit page (independent uploads)
- list page no longer mints a presigned URL per company (moved to edit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-21 01:45:59 +05:30
parent 467f0ddea4
commit bad67f66c4
6 changed files with 151 additions and 132 deletions

View file

@ -0,0 +1,39 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { generateDownloadUrl } from "@/lib/storage";
import { redirect, notFound } from "next/navigation";
import { CompanyForm } from "../../company-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Edit Company" };
export default async function EditCompanyPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const { id } = await params;
const c = await db.company.findUnique({ where: { id } });
if (!c) notFound();
return (
<CompanyForm
company={{
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,
}}
/>
);
}

View file

@ -30,7 +30,7 @@ const companySchema = z.object({
invoiceAddress: z.string().optional(), invoiceAddress: z.string().optional(),
}); });
export async function createCompany(formData: FormData): Promise<ActionResult> { export async function createCompany(formData: FormData): Promise<{ ok: true; id: string } | { error: string }> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) { if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" }; return { error: "Unauthorized" };
@ -54,11 +54,11 @@ export async function createCompany(formData: FormData): Promise<ActionResult> {
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } }); const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } });
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` }; if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
} }
await db.company.create({ const created = await db.company.create({
data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null }, data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
}); });
revalidatePath("/admin/companies"); revalidatePath("/admin/companies");
return { ok: true }; return { ok: true, id: created.id };
} }
export async function updateCompany(formData: FormData): Promise<ActionResult> { export async function updateCompany(formData: FormData): Promise<ActionResult> {

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { AddCompanyButton, EditCompanyButton } from "./company-form"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@ -18,27 +19,24 @@ export type CompanyRow = {
email: string | null; email: string | null;
invoiceEmail: string | null; invoiceEmail: string | null;
invoiceAddress: string | null; invoiceAddress: string | null;
logoUrl: string | null;
stampUrl: string | null;
isActive: boolean; isActive: boolean;
}; };
function CompanyActionsMenu({ company }: { company: CompanyRow }) { function CompanyActionsMenu({ company }: { company: CompanyRow }) {
const [editOpen, setEditOpen] = useState(false); const router = useRouter();
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false);
return ( return (
<> <>
<RowActionsMenu> <RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem> <RowActionsItem onClick={() => router.push(`/admin/companies/${company.id}/edit`)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}> <RowActionsItem onClick={() => setToggleOpen(true)}>
{company.isActive ? "Deactivate" : "Activate"} {company.isActive ? "Deactivate" : "Activate"}
</RowActionsItem> </RowActionsItem>
<RowActionsSeparator /> <RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem> <RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu> </RowActionsMenu>
<EditCompanyButton company={company} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog <DeleteConfirmDialog
open={deleteOpen} onOpenChange={setDeleteOpen} open={deleteOpen} onOpenChange={setDeleteOpen}
label={company.name} onConfirm={() => deleteCompany(company.id)} label={company.name} onConfirm={() => deleteCompany(company.id)}
@ -62,7 +60,10 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
<h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1> <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> <p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p>
</div> </div>
<AddCompanyButton /> <Link href="/admin/companies/new"
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
</Link>
</div> </div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">

View file

@ -2,11 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; 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 { createCompany, updateCompany } from "./actions";
import { CompanyBrandingUploader } from "./company-branding-uploader"; import { CompanyBrandingUploader } from "./company-branding-uploader";
type CompanyRow = { export type CompanyFormData = {
id: string; id: string;
name: string; name: string;
code: string | null; 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 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"; const LABEL = "block text-xs font-medium text-neutral-700 mb-1";
function CompanyFormFields({ company }: { company?: CompanyRow }) { function CompanyFormFields({ company }: { company?: CompanyFormData }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
@ -70,117 +71,83 @@ function CompanyFormFields({ company }: { company?: CompanyRow }) {
<label className={LABEL}>Invoice Address <span className="font-normal text-neutral-400">(shown on exported POs)</span></label> <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" /> <textarea name="invoiceAddress" defaultValue={company?.invoiceAddress ?? ""} rows={2} className={INPUT} placeholder="Full address as it should appear on invoices/POs" />
</div> </div>
</div>
);
}
{/* ── Branding (shown on exported POs) ── */} export function CompanyForm({ company }: { company?: CompanyFormData }) {
<div className="border-t border-neutral-200 pt-3 mt-1"> const router = useRouter();
<p className="text-xs font-semibold text-neutral-700 mb-2">Branding <span className="font-normal text-neutral-400">(shown on exported POs)</span></p> const isEdit = !!company?.id;
{company?.id ? ( const [pending, setPending] = useState(false);
<div className="grid grid-cols-2 gap-3"> const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<div className="max-w-3xl">
<Link href="/admin/companies" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-700 mb-3">
<ArrowLeft className="h-3.5 w-3.5" /> Back to Companies
</Link>
<h1 className="text-2xl font-semibold text-neutral-900">{isEdit ? `Edit — ${company!.name}` : "Add Company"}</h1>
<p className="text-sm text-neutral-500 mt-0.5 mb-6">Sister company used for invoicing and purchase orders</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<CompanyFormFields company={company} />
</div>
{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">
<Link href="/admin/companies"
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</Link>
<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 ? (isEdit ? "Saving…" : "Creating…") : (isEdit ? "Save Changes" : "Create Company")}
</button>
</div>
</form>
{/* ── Branding (independent uploads; available once the company exists) ── */}
<div className="rounded-lg border border-neutral-200 bg-white p-5 mt-6">
<h2 className="text-sm font-semibold text-neutral-800">Branding</h2>
<p className="text-xs text-neutral-400 mb-3">Logo and stamp shown on exported POs</p>
{isEdit ? (
<div className="grid grid-cols-2 gap-4">
<CompanyBrandingUploader <CompanyBrandingUploader
companyId={company.id} type="logo" label="Logo" companyId={company!.id} type="logo" label="Logo"
hint="PNG, JPG or WebP — shown top-left. Max 4 MB" hint="PNG, JPG or WebP — shown top-left. Max 4 MB"
currentUrl={company.logoUrl} currentUrl={company!.logoUrl}
/> />
<CompanyBrandingUploader <CompanyBrandingUploader
companyId={company.id} type="stamp" label="Stamp / Seal" companyId={company!.id} type="stamp" label="Stamp / Seal"
hint="PNG, JPG or WebP — shown in signatory block. Max 4 MB" hint="PNG, JPG or WebP — shown in signatory block. Max 4 MB"
currentUrl={company.stampUrl} currentUrl={company!.stampUrl}
/> />
</div> </div>
) : ( ) : (
<p className="text-xs text-neutral-400">Save the company first, then upload a logo and stamp from Edit.</p> <p className="text-xs text-neutral-400">Create the company first you&apos;ll be taken to the edit page where you can upload a logo and stamp.</p>
)} )}
</div> </div>
</div> </div>
); );
} }
export function AddCompanyButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createCompany(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Company
</button>
<AdminDialog title="Add Company" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CompanyFormFields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Company"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditCompanyButton({
company,
open: controlledOpen,
onOpenChange,
}: {
company: CompanyRow;
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", company.id);
const result = await updateCompany(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
{!isControlled && (
<button onClick={() => setOpen(true)}
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
Edit
</button>
)}
<AdminDialog title={`Edit — ${company.name}`} open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CompanyFormFields company={company} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,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 <CompanyForm />;
}

View file

@ -1,7 +1,6 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { generateDownloadUrl } from "@/lib/storage";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { CompaniesTable } from "./companies-table"; import { CompaniesTable } from "./companies-table";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -17,23 +16,21 @@ export default async function AdminCompaniesPage() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
const rows = await Promise.all( return (
companies.map(async (c) => ({ <CompaniesTable
id: c.id, companies={companies.map((c) => ({
name: c.name, id: c.id,
code: c.code, name: c.name,
gstNumber: c.gstNumber, code: c.code,
address: c.address, gstNumber: c.gstNumber,
telephone: c.telephone, address: c.address,
mobile: c.mobile, telephone: c.telephone,
email: c.email, mobile: c.mobile,
invoiceEmail: c.invoiceEmail, email: c.email,
invoiceAddress: c.invoiceAddress, invoiceEmail: c.invoiceEmail,
logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null, invoiceAddress: c.invoiceAddress,
stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null, isActive: c.isActive,
isActive: c.isActive, }))}
})) />
); );
return <CompaniesTable companies={rows} />;
} }