pelagia-portal/App/lib/utils.ts
Claude (auto-fix) defd6e7a18
All checks were successful
PR checks / checks (pull_request) Successful in 31s
feat(dashboard): compact INR formatting for Total Approved Spend card
Overhaul the manager dashboard "Total Approved Spend" stat card per the
reporter's request:

- Swap the DollarSign lucide icon for IndianRupee (rupee symbol).
- Render the amount in the Indian short scale (lakh/crore) via a new
  `formatCompactINR` helper, e.g. ₹2 Cr, ₹49 L, ₹75 K, instead of the full
  ₹49,00,000.00.

`formatCompactINR` rounds to at most 2 decimals, trims trailing zeros, keeps
the ₹ prefix and sign. The DollarSign icon is retained for the Accounts
"Payment Queue Value" card; the precise `formatCurrency` is kept for tables.
Adds unit tests covering crore/lakh/thousand/sub-thousand, boundaries, zero,
string input, negatives and non-finite input.

Fixes #50

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 02:02:41 +05:30

113 lines
3.3 KiB
TypeScript

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import type { POStatus } from "@prisma/client";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number | string, currency = "INR"): string {
return new Intl.NumberFormat("en-IN", { style: "currency", currency }).format(
Number(amount)
);
}
// 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",
month: "short",
day: "numeric",
}).format(new Date(date));
}
export function formatDateTime(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
export function generatePoNumber(): string {
const year = new Date().getFullYear();
const seq = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, "0");
return `PO-${year}-${seq}`;
}
export const PO_STATUS_LABELS: Record<POStatus, string> = {
DRAFT: "Draft",
SUBMITTED: "Submitted",
MGR_REVIEW: "Under Review",
VENDOR_ID_PENDING: "Vendor ID Pending",
EDITS_REQUESTED: "Edits Requested",
REJECTED: "Rejected",
MGR_APPROVED: "Approved",
SENT_FOR_PAYMENT: "Sent for Payment",
PARTIALLY_PAID: "Partially Paid",
PAID_DELIVERED: "Paid",
PARTIALLY_CLOSED: "Partially Received",
CLOSED: "Closed",
};
// Statuses a PO can be in once it has received manager approval. A PO keeps its
// `approvedAt` timestamp as it moves through these states, so "approved this month"
// aggregations must match against all of them — not just MGR_APPROVED.
export const POST_APPROVAL_STATUSES = [
"MGR_APPROVED",
"SENT_FOR_PAYMENT",
"PARTIALLY_PAID",
"PAID_DELIVERED",
"PARTIALLY_CLOSED",
"CLOSED",
] as const satisfies readonly POStatus[];
export type BadgeVariant =
| "default"
| "secondary"
| "success"
| "warning"
| "danger"
| "outline";
export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
DRAFT: "outline",
SUBMITTED: "secondary",
MGR_REVIEW: "secondary",
VENDOR_ID_PENDING: "warning",
EDITS_REQUESTED: "warning",
REJECTED: "danger",
MGR_APPROVED: "success",
SENT_FOR_PAYMENT: "default",
PARTIALLY_PAID: "warning",
PAID_DELIVERED: "success",
PARTIALLY_CLOSED: "warning",
CLOSED: "secondary",
};