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:
74
apps/web/components/reports/report-card.tsx
Normal file
74
apps/web/components/reports/report-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
22
apps/web/components/reports/report-status-badge.tsx
Normal file
22
apps/web/components/reports/report-status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/reports/report-type-badge.tsx
Normal file
36
apps/web/components/reports/report-type-badge.tsx
Normal 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,
|
||||
}));
|
||||
Reference in New Issue
Block a user