feat(web): centralise Vietnamese price formatting across all pages

Create a single `currency.ts` utility with `formatPrice`, `formatVND`,
`formatPricePerM2`, and `parseVND` to replace 9+ duplicated inline
formatters. This fixes inconsistent decimal handling (1.5M was truncated
to "1 triệu") and standardises price/m² display. Integrated across
property cards, listing detail, dashboard, analytics, payments, pricing,
and admin moderation pages with 19 new unit tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:33:31 +07:00
parent 18b5980f29
commit 55a01c5738
12 changed files with 285 additions and 107 deletions

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import {
useMarketReport,
useHeatmap,
@@ -36,18 +37,6 @@ const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
const CURRENT_PERIOD = '2026-Q1';
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} đ/m²`;
}
function YoYBadge({ value }: { value: number | null }) {
if (value === null) return <span className="text-xs text-muted-foreground">N/A</span>;
const isPositive = value >= 0;
@@ -124,7 +113,7 @@ export default function AnalyticsPage() {
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Phân tích thị trường</h1>
<h1 className="text-2xl font-bold sm:text-3xl">Phân tích thị trường</h1>
<p className="mt-2 text-muted-foreground">
Báo cáo thị trường bất đng sản - {period}
</p>
@@ -159,7 +148,7 @@ export default function AnalyticsPage() {
<CardHeader className="pb-2">
<CardDescription>Giá TB/m²</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : formatPriceM2(avgPriceM2)}
{loading ? '...' : formatPricePerM2(avgPriceM2)}
</CardTitle>
</CardHeader>
</Card>
@@ -183,11 +172,11 @@ export default function AnalyticsPage() {
{/* Tabs */}
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
<TabsTrigger value="performance">Hiệu suất</TabsTrigger>
<TabsList className="w-full justify-start overflow-x-auto">
<TabsTrigger value="overview" className="min-w-fit">Tổng quan</TabsTrigger>
<TabsTrigger value="trends" className="min-w-fit">Xu hướng giá</TabsTrigger>
<TabsTrigger value="districts" className="min-w-fit">Chi tiết quận</TabsTrigger>
<TabsTrigger value="performance" className="min-w-fit">Hiệu suất</TabsTrigger>
</TabsList>
{/* Overview Tab */}
@@ -336,7 +325,7 @@ export default function AnalyticsPage() {
{formatPrice(stat.medianPrice)}
</td>
<td className="py-2 pr-4 text-right">
{formatPriceM2(stat.avgPriceM2)}
{formatPricePerM2(stat.avgPriceM2)}
</td>
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
<td className="py-2 pr-4 text-right">
@@ -384,7 +373,7 @@ export default function AnalyticsPage() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Giá/m²</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
<span>{formatPricePerM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin đăng</span>