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:
barsa 2025-11-17 10:50:12 +09:00
parent d943d04754
commit 01d5127351
5 changed files with 100 additions and 66 deletions

View File

@ -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>
);
}

View File

@ -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`

View File

@ -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")}

View File

@ -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;

View File

@ -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,
});
};