Compare commits
7 commits
991b7ca5dd
...
9e787fd15f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e787fd15f | |||
| 4712fafb4b | |||
| e388ec917e | |||
| 9d08ca1990 | |||
| 6137d11e5f | |||
|
|
defd6e7a18 | ||
| bad67f66c4 |
10 changed files with 312 additions and 136 deletions
39
App/app/(portal)/admin/companies/[id]/edit/page.tsx
Normal file
39
App/app/(portal)/admin/companies/[id]/edit/page.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ const companySchema = z.object({
|
|||
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();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
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" } } });
|
||||
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 },
|
||||
});
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true };
|
||||
return { ok: true, id: created.id };
|
||||
}
|
||||
|
||||
export async function updateCompany(formData: FormData): Promise<ActionResult> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
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 { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
|
@ -18,27 +19,24 @@ export type CompanyRow = {
|
|||
email: string | null;
|
||||
invoiceEmail: string | null;
|
||||
invoiceAddress: string | null;
|
||||
logoUrl: string | null;
|
||||
stampUrl: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
function CompanyActionsMenu({ company }: { company: CompanyRow }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [toggleOpen, setToggleOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => router.push(`/admin/companies/${company.id}/edit`)}>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)}
|
||||
|
|
@ -62,7 +60,10 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
|
|||
<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 />
|
||||
<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 className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-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>
|
||||
<textarea name="invoiceAddress" defaultValue={company?.invoiceAddress ?? ""} rows={2} className={INPUT} placeholder="Full address as it should appear on invoices/POs" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* ── Branding (shown on exported POs) ── */}
|
||||
<div className="border-t border-neutral-200 pt-3 mt-1">
|
||||
<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>
|
||||
{company?.id ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
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<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
|
||||
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"
|
||||
currentUrl={company.logoUrl}
|
||||
currentUrl={company!.logoUrl}
|
||||
/>
|
||||
<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"
|
||||
currentUrl={company.stampUrl}
|
||||
currentUrl={company!.stampUrl}
|
||||
/>
|
||||
</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'll be taken to the edit page where you can upload a logo and stamp.</p>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
15
App/app/(portal)/admin/companies/new/page.tsx
Normal file
15
App/app/(portal)/admin/companies/new/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<CompaniesTable
|
||||
companies={companies.map((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,
|
||||
isActive: c.isActive,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
return <CompaniesTable companies={rows} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { db } from "@/lib/db";
|
|||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
||||
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ async function ManagerDashboard() {
|
|||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
||||
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
||||
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
|
||||
</div>
|
||||
|
||||
{/* Recent approved POs */}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,30 @@ export function formatCurrency(amount: number | string, currency = "INR"): strin
|
|||
);
|
||||
}
|
||||
|
||||
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
|
||||
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
|
||||
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
|
||||
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
|
||||
export function formatCompactINR(amount: number | string): string {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return "₹0";
|
||||
|
||||
const sign = n < 0 ? "-" : "";
|
||||
const abs = Math.abs(n);
|
||||
|
||||
const format = (value: number, suffix: string) => {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
|
||||
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${sign}₹${text}${suffix}`;
|
||||
};
|
||||
|
||||
if (abs >= 1e7) return format(abs / 1e7, " Cr");
|
||||
if (abs >= 1e5) return format(abs / 1e5, " L");
|
||||
if (abs >= 1e3) return format(abs / 1e3, " K");
|
||||
return format(abs, "");
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
|
|
|
|||
84
App/tests/integration/company-crud.test.ts
Normal file
84
App/tests/integration/company-crud.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatCurrency, formatDate, formatDateTime,
|
||||
formatCurrency, formatCompactINR, formatDate, formatDateTime,
|
||||
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
|
||||
} from "@/lib/utils";
|
||||
|
||||
|
|
@ -32,6 +32,55 @@ describe("formatCurrency", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("formatCompactINR", () => {
|
||||
it("abbreviates crore amounts with Cr", () => {
|
||||
expect(formatCompactINR(20000000)).toBe("₹2 Cr");
|
||||
});
|
||||
|
||||
it("abbreviates lakh amounts with L", () => {
|
||||
expect(formatCompactINR(4900000)).toBe("₹49 L");
|
||||
});
|
||||
|
||||
it("abbreviates thousand amounts with K", () => {
|
||||
expect(formatCompactINR(75000)).toBe("₹75 K");
|
||||
});
|
||||
|
||||
it("renders sub-thousand amounts without a suffix", () => {
|
||||
expect(formatCompactINR(500)).toBe("₹500");
|
||||
});
|
||||
|
||||
it("formats zero as ₹0", () => {
|
||||
expect(formatCompactINR(0)).toBe("₹0");
|
||||
});
|
||||
|
||||
it("trims trailing zeros but keeps significant decimals", () => {
|
||||
expect(formatCompactINR(25000000)).toBe("₹2.5 Cr");
|
||||
expect(formatCompactINR(4950000)).toBe("₹49.5 L");
|
||||
});
|
||||
|
||||
it("rounds to at most two decimals", () => {
|
||||
expect(formatCompactINR(12345678)).toBe("₹1.23 Cr");
|
||||
});
|
||||
|
||||
it("uses the right unit at boundaries", () => {
|
||||
expect(formatCompactINR(100000)).toBe("₹1 L");
|
||||
expect(formatCompactINR(10000000)).toBe("₹1 Cr");
|
||||
expect(formatCompactINR(1000)).toBe("₹1 K");
|
||||
});
|
||||
|
||||
it("accepts string input", () => {
|
||||
expect(formatCompactINR("4900000")).toBe("₹49 L");
|
||||
});
|
||||
|
||||
it("preserves the sign for negative amounts", () => {
|
||||
expect(formatCompactINR(-4900000)).toBe("-₹49 L");
|
||||
});
|
||||
|
||||
it("handles non-finite input gracefully", () => {
|
||||
expect(formatCompactINR(NaN)).toBe("₹0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("returns a readable date string", () => {
|
||||
const result = formatDate(new Date("2026-04-29"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue