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:
152
apps/web/components/reports/report-chart.tsx
Normal file
152
apps/web/components/reports/report-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user