Refactor InvoiceStatusBadge component and enhance currency formatting
- Updated InvoiceStatusBadge to utilize a configuration object for status handling, improving maintainability and readability. - Added support for additional invoice statuses including Draft, Pending, Refunded, and Collections. - Refactored currency formatting logic in dashboard utilities to use a centralized utility function, ensuring consistent currency display across the application. - Enhanced useFormatCurrency hook to support more flexible currency formatting options, improving usability in various contexts.
This commit is contained in:
parent
d943d04754
commit
01d5127351
@ -1,47 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { InvoiceStatus } from "@customer-portal/domain/billing";
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: string }) {
|
||||
const icon = (() => {
|
||||
switch (status) {
|
||||
case "Paid":
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case "Overdue":
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
|
||||
case "Unpaid":
|
||||
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
|
||||
default:
|
||||
return <DocumentTextIcon className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
})();
|
||||
type StatusConfig = {
|
||||
icon: ReactElement;
|
||||
color: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const color = (() => {
|
||||
switch (status) {
|
||||
case "Paid":
|
||||
return "bg-green-100 text-green-800 border-green-200";
|
||||
case "Overdue":
|
||||
return "bg-red-100 text-red-800 border-red-200";
|
||||
case "Unpaid":
|
||||
return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||
case "Cancelled":
|
||||
return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
})();
|
||||
const STATUS_CONFIG: Record<InvoiceStatus, StatusConfig> = {
|
||||
Draft: {
|
||||
icon: <DocumentTextIcon className="h-5 w-5 text-gray-500" />,
|
||||
color: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
label: "Draft",
|
||||
},
|
||||
Pending: {
|
||||
icon: <ClockIcon className="h-5 w-5 text-blue-500" />,
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
label: "Pending",
|
||||
},
|
||||
Paid: {
|
||||
icon: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
|
||||
color: "bg-green-100 text-green-800 border-green-200",
|
||||
label: "Paid",
|
||||
},
|
||||
Unpaid: {
|
||||
icon: <ClockIcon className="h-5 w-5 text-yellow-500" />,
|
||||
color: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
label: "Unpaid",
|
||||
},
|
||||
Overdue: {
|
||||
icon: <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />,
|
||||
color: "bg-red-100 text-red-800 border-red-200",
|
||||
label: "Overdue",
|
||||
},
|
||||
Cancelled: {
|
||||
icon: <DocumentTextIcon className="h-5 w-5 text-gray-500" />,
|
||||
color: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
label: "Cancelled",
|
||||
},
|
||||
Refunded: {
|
||||
icon: <CheckCircleIcon className="h-5 w-5 text-emerald-500" />,
|
||||
color: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
label: "Refunded",
|
||||
},
|
||||
Collections: {
|
||||
icon: <ExclamationTriangleIcon className="h-5 w-5 text-red-600" />,
|
||||
color: "bg-red-100 text-red-900 border-red-200",
|
||||
label: "Collections",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_STATUS: StatusConfig = {
|
||||
icon: <DocumentTextIcon className="h-5 w-5 text-gray-500" />,
|
||||
color: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
label: "Unknown",
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
|
||||
const config = STATUS_CONFIG[status] ?? DEFAULT_STATUS;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-full border ${color}`}
|
||||
className={`inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-full border ${config.color}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="ml-1">{status}</span>
|
||||
{config.icon}
|
||||
<span className="ml-1">{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ActivityFilter,
|
||||
ActivityFilterConfig,
|
||||
} from "@customer-portal/domain/dashboard";
|
||||
import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit";
|
||||
|
||||
/**
|
||||
* Activity filter configurations
|
||||
@ -124,39 +125,13 @@ export function getActivityIconGradient(activityType: Activity["type"]): string
|
||||
return gradientMap[activityType] || "from-gray-500 to-slate-500";
|
||||
}
|
||||
|
||||
const currencyFormatterCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
const formatCurrency = (amount: number, currency?: string) => {
|
||||
const code = (currency || "JPY").toUpperCase();
|
||||
const formatter =
|
||||
currencyFormatterCache.get(code) ||
|
||||
(() => {
|
||||
try {
|
||||
const intl = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
});
|
||||
currencyFormatterCache.set(code, intl);
|
||||
return intl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!formatter) {
|
||||
return `${code} ${amount.toLocaleString()}`;
|
||||
}
|
||||
|
||||
return formatter.format(amount);
|
||||
};
|
||||
|
||||
export function formatActivityDescription(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "invoice_created":
|
||||
case "invoice_paid": {
|
||||
const parsed = invoiceActivityMetadataSchema.safeParse(activity.metadata ?? {});
|
||||
if (parsed.success && typeof parsed.data.amount === "number") {
|
||||
const formattedAmount = formatCurrency(parsed.data.amount, parsed.data.currency);
|
||||
const formattedAmount = formatCurrencyUtil(parsed.data.amount, parsed.data.currency);
|
||||
if (formattedAmount) {
|
||||
return activity.type === "invoice_paid"
|
||||
? `${formattedAmount} payment completed`
|
||||
|
||||
@ -193,7 +193,9 @@ export function DashboardView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(upcomingInvoice.amount)}
|
||||
{formatCurrency(upcomingInvoice.amount, {
|
||||
currency: upcomingInvoice.currency,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
|
||||
|
||||
@ -19,7 +19,7 @@ export function useCurrency() {
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const resolvedCurrency = data ?? null;
|
||||
const resolvedCurrency = data ?? (isError ? FALLBACK_CURRENCY : null);
|
||||
const currencyCode = resolvedCurrency?.code ?? FALLBACK_CURRENCY.code;
|
||||
const currencySymbol = resolvedCurrency?.prefix ?? FALLBACK_CURRENCY.prefix;
|
||||
|
||||
|
||||
@ -11,17 +11,43 @@ export type FormatCurrencyOptions = {
|
||||
showSymbol?: boolean;
|
||||
};
|
||||
|
||||
const isOptions = (
|
||||
value: string | FormatCurrencyOptions | undefined
|
||||
): value is FormatCurrencyOptions => {
|
||||
return typeof value === "object" && value !== null;
|
||||
};
|
||||
|
||||
export function useFormatCurrency() {
|
||||
const { currencyCode, currencySymbol, loading, error } = useCurrency();
|
||||
|
||||
const formatCurrency = (amount: number, options?: FormatCurrencyOptions) => {
|
||||
const resolvedCurrency = options?.currency ?? currencyCode ?? FALLBACK_CURRENCY.code;
|
||||
const resolvedSymbol = options?.currencySymbol ?? currencySymbol ?? FALLBACK_CURRENCY.prefix;
|
||||
const formatCurrency = (
|
||||
amount: number,
|
||||
currencyOrOptions?: string | FormatCurrencyOptions,
|
||||
options?: FormatCurrencyOptions
|
||||
) => {
|
||||
const fallbackCurrency = currencyCode ?? FALLBACK_CURRENCY.code;
|
||||
const fallbackSymbol = currencySymbol ?? FALLBACK_CURRENCY.prefix;
|
||||
|
||||
return baseFormatCurrency(amount, resolvedCurrency, {
|
||||
currencySymbol: resolvedSymbol,
|
||||
locale: options?.locale,
|
||||
showSymbol: options?.showSymbol,
|
||||
const overrideCurrency =
|
||||
(typeof currencyOrOptions === "string" && currencyOrOptions) ||
|
||||
(isOptions(currencyOrOptions) ? currencyOrOptions.currency : undefined) ||
|
||||
options?.currency;
|
||||
|
||||
const overrideSymbol =
|
||||
(isOptions(currencyOrOptions) ? currencyOrOptions.currencySymbol : undefined) ||
|
||||
options?.currencySymbol;
|
||||
|
||||
const locale =
|
||||
(isOptions(currencyOrOptions) ? currencyOrOptions.locale : undefined) || options?.locale;
|
||||
const showSymbol =
|
||||
(isOptions(currencyOrOptions) && typeof currencyOrOptions.showSymbol === "boolean"
|
||||
? currencyOrOptions.showSymbol
|
||||
: undefined) ?? options?.showSymbol;
|
||||
|
||||
return baseFormatCurrency(amount, overrideCurrency ?? fallbackCurrency, {
|
||||
currencySymbol: overrideSymbol ?? fallbackSymbol,
|
||||
locale,
|
||||
showSymbol,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user