feat(web): add khu-cong-nghiep, chuyen-nhuong, and reports pages

Add three new frontend page sections:
- Industrial parks (khu-cong-nghiep): listing, detail, filter bar
- Transfer listings (chuyen-nhuong): search, category tabs, detail
- AI reports dashboard: list, create, viewer with TOC

Includes components, API clients, hooks, server helpers, i18n keys,
navigation links in public and dashboard layouts, and lint fixes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:07:45 +07:00
parent 62a8842193
commit 7ce651fce5
30 changed files with 2874 additions and 1 deletions

View File

@@ -0,0 +1,74 @@
'use client';
import { Calendar, Trash2, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import type { Report } from '@/lib/reports-api';
import { ReportStatusBadge } from './report-status-badge';
import { ReportTypeBadge } from './report-type-badge';
interface ReportCardProps {
report: Report;
onDelete?: (id: string) => void;
}
export function ReportCard({ report, onDelete }: ReportCardProps) {
const date = new Date(report.createdAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return (
<div className="group rounded-lg border bg-card p-4 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<ReportTypeBadge type={report.type} />
<ReportStatusBadge status={report.status} />
</div>
<h3 className="line-clamp-1 text-sm font-semibold">{report.title}</h3>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{date}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{report.status === 'READY' && (
<Link href={`/dashboard/reports/${report.id}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => onDelete(report.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
{report.status === 'READY' && (
<Link
href={`/dashboard/reports/${report.id}`}
className="mt-3 block text-xs font-medium text-primary hover:underline"
>
Xem báo cáo
</Link>
)}
{report.status === 'FAILED' && report.errorMsg && (
<p className="mt-2 text-xs text-destructive">{report.errorMsg}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import * as React from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface DataPoint {
period: string;
value: number;
unit: string;
}
interface ReportChartProps {
data: DataPoint[];
title: string;
variant?: 'area' | 'bar';
color?: string;
height?: number;
}
const COLORS = {
primary: '#2563eb',
secondary: '#16a34a',
accent: '#d97706',
};
function formatValue(value: number, unit: string): string {
if (unit === 'tỷ USD' || unit === 'tỷ VND') {
return `${value.toLocaleString('vi-VN')} ${unit}`;
}
if (unit === '%') {
return `${value}%`;
}
if (unit === 'người' || unit === 'triệu người') {
return value.toLocaleString('vi-VN');
}
return `${value.toLocaleString('vi-VN')} ${unit}`;
}
export function ReportChart({
data,
title,
variant = 'area',
color = COLORS.primary,
height = 240,
}: ReportChartProps) {
if (!data || data.length === 0) return null;
const unit = data[0]?.unit ?? '';
const chartData = data.map((d) => ({
name: d.period,
value: d.value,
}));
return (
<div className="rounded-lg border bg-card p-4">
<h4 className="mb-3 text-sm font-medium text-muted-foreground">{title}</h4>
<ResponsiveContainer width="100%" height={height}>
{variant === 'bar' ? (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number) => [formatValue(value, unit), title]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number) => [formatValue(value, unit), title]}
contentStyle={{ fontSize: 12 }}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
fill={color}
fillOpacity={0.1}
strokeWidth={2}
/>
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
}
interface ReportChartsGridProps {
charts: Record<string, DataPoint[]>;
labels?: Record<string, string>;
}
const DEFAULT_CHART_LABELS: Record<string, string> = {
gdp_trend: 'GDP',
fdi_trend: 'Vốn FDI',
population: 'Dân số',
urbanization: 'Đô thị hóa',
labor_force: 'Lực lượng lao động',
avg_wage: 'Lương bình quân',
industrial_output: 'Sản lượng công nghiệp',
cpi: 'Chỉ số giá tiêu dùng',
mortgage_rate: 'Lãi suất vay',
};
const CHART_COLORS: Record<string, string> = {
gdp_trend: COLORS.primary,
fdi_trend: COLORS.secondary,
population: COLORS.accent,
urbanization: COLORS.primary,
labor_force: COLORS.secondary,
avg_wage: COLORS.accent,
};
export function ReportChartsGrid({ charts, labels }: ReportChartsGridProps) {
const mergedLabels = { ...DEFAULT_CHART_LABELS, ...labels };
const validCharts = Object.entries(charts).filter(
([, data]) => Array.isArray(data) && data.length > 0,
);
if (validCharts.length === 0) return null;
return (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{validCharts.map(([key, data]) => (
<ReportChart
key={key}
data={data as DataPoint[]}
title={mergedLabels[key] ?? key}
color={CHART_COLORS[key] ?? COLORS.primary}
variant={key.includes('trend') ? 'area' : 'bar'}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
import type { ReportStatus } from '@/lib/reports-api';
const statusConfig: Record<ReportStatus, { label: string; icon: typeof CheckCircle; className: string }> = {
GENERATING: { label: 'Đang tạo...', icon: Loader2, className: 'text-blue-600 bg-blue-50' },
READY: { label: 'Hoàn thành', icon: CheckCircle, className: 'text-green-600 bg-green-50' },
FAILED: { label: 'Lỗi', icon: XCircle, className: 'text-red-600 bg-red-50' },
};
export function ReportStatusBadge({ status }: { status: ReportStatus }) {
const config = statusConfig[status];
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${config.className}`}>
<Icon className={`h-3 w-3 ${status === 'GENERATING' ? 'animate-spin' : ''}`} />
{config.label}
</span>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Building2, Factory, MapPin, TrendingUp, Warehouse, Calculator, Briefcase } from 'lucide-react';
import type { ReportType } from '@/lib/reports-api';
const typeConfig: Record<ReportType, { label: string; icon: typeof Building2; className: string }> = {
RESIDENTIAL_MARKET: { label: 'Nhà ở', icon: Building2, className: 'text-emerald-700 bg-emerald-50' },
INDUSTRIAL_MARKET: { label: 'KCN', icon: Factory, className: 'text-orange-700 bg-orange-50' },
DISTRICT_ANALYSIS: { label: 'Quận/Huyện', icon: MapPin, className: 'text-purple-700 bg-purple-50' },
INVESTMENT_FEASIBILITY: { label: 'Đầu tư', icon: TrendingUp, className: 'text-blue-700 bg-blue-50' },
INDUSTRIAL_LOCATION: { label: 'Vị trí KCN', icon: Warehouse, className: 'text-amber-700 bg-amber-50' },
PROPERTY_VALUATION: { label: 'Định giá', icon: Calculator, className: 'text-teal-700 bg-teal-50' },
PORTFOLIO: { label: 'Danh mục', icon: Briefcase, className: 'text-indigo-700 bg-indigo-50' },
};
export function ReportTypeBadge({ type }: { type: ReportType }) {
const config = typeConfig[type];
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${config.className}`}>
<Icon className="h-3 w-3" />
{config.label}
</span>
);
}
export function getReportTypeLabel(type: ReportType): string {
return typeConfig[type]?.label ?? type;
}
export const REPORT_TYPES = Object.entries(typeConfig).map(([value, config]) => ({
value: value as ReportType,
label: config.label,
icon: config.icon,
}));