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:
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, Download, Loader2, Printer } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { ReportChartsGrid } from '@/components/reports/report-chart';
|
||||
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
|
||||
import { ReportTypeBadge } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useReport, useReportStatus } from '@/lib/hooks/use-reports';
|
||||
|
||||
interface ReportSection {
|
||||
title: string;
|
||||
content?: string;
|
||||
data?: unknown;
|
||||
charts?: Record<string, unknown>;
|
||||
projects?: unknown[];
|
||||
summary?: unknown;
|
||||
}
|
||||
|
||||
export default function ReportViewerPage() {
|
||||
const routeParams = useParams();
|
||||
const reportId = routeParams['id'] as string;
|
||||
|
||||
const { data: report, isLoading, refetch } = useReport(reportId);
|
||||
const shouldPoll = report?.status === 'GENERATING';
|
||||
const { data: statusData } = useReportStatus(reportId, shouldPoll);
|
||||
|
||||
// Refresh full report when status transitions to READY
|
||||
React.useEffect(() => {
|
||||
if (statusData?.status === 'READY' && report?.status === 'GENERATING') {
|
||||
refetch();
|
||||
}
|
||||
}, [statusData?.status, report?.status, refetch]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-muted-foreground">Không tìm thấy báo cáo.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = (report.content as { sections?: Record<string, ReportSection> })?.sections;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Link href="/dashboard/reports">
|
||||
<Button variant="ghost" size="sm" className="mt-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ReportTypeBadge type={report.type} />
|
||||
<ReportStatusBadge status={report.status} />
|
||||
</div>
|
||||
<h1 className="mt-1 text-xl font-bold">{report.title}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tạo lúc {new Date(report.createdAt).toLocaleString('vi-VN')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.status === 'READY' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{report.pdfUrl && (
|
||||
<a href={report.pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
PDF
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
||||
<Printer className="mr-1 h-4 w-4" />
|
||||
In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generating state */}
|
||||
{report.status === 'GENERATING' && (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
|
||||
<Loader2 className="mb-4 h-10 w-10 animate-spin text-primary" />
|
||||
<h3 className="text-lg font-medium">Đang tạo báo cáo...</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Quá trình này có thể mất vài phút. Trang sẽ tự cập nhật khi hoàn thành.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed state */}
|
||||
{report.status === 'FAILED' && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6">
|
||||
<h3 className="font-medium text-destructive">Tạo báo cáo thất bại</h3>
|
||||
{report.errorMsg && (
|
||||
<p className="mt-1 text-sm text-destructive/80">{report.errorMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report content */}
|
||||
{report.status === 'READY' && sections && (
|
||||
<div className="grid gap-4 lg:grid-cols-[200px_1fr]">
|
||||
{/* Sidebar TOC */}
|
||||
<nav className="hidden lg:block">
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Mục lục</p>
|
||||
<ul className="space-y-1">
|
||||
{Object.entries(sections).map(([key, section]) => (
|
||||
<li key={key}>
|
||||
<a
|
||||
href={`#section-${key}`}
|
||||
className="block rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{section.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Content sections */}
|
||||
<div className="space-y-4">
|
||||
{Object.entries(sections).map(([key, section]) => (
|
||||
<section
|
||||
key={key}
|
||||
id={`section-${key}`}
|
||||
className="rounded-lg border p-6"
|
||||
>
|
||||
<h2 className="mb-3 text-lg font-semibold">{section.title}</h2>
|
||||
{section.content && (
|
||||
<div className="space-y-2 text-sm leading-relaxed text-muted-foreground whitespace-pre-line">
|
||||
{String(section.content)}
|
||||
</div>
|
||||
)}
|
||||
{section.charts && (
|
||||
<ReportChartsGrid charts={section.charts as Record<string, Array<{ period: string; value: number; unit: string }>>} />
|
||||
)}
|
||||
{section.data != null && !section.charts && (
|
||||
<ReportChartsGrid charts={section.data as Record<string, Array<{ period: string; value: number; unit: string }>>} />
|
||||
)}
|
||||
{section.projects && Array.isArray(section.projects) && section.projects.length > 0 && (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="pb-2 pr-4">Tên</th>
|
||||
<th className="pb-2 pr-4">Loại</th>
|
||||
<th className="pb-2 pr-4">Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.projects.map((proj: unknown, i: number) => {
|
||||
const p = proj as Record<string, unknown>;
|
||||
return (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{String(p['name'] ?? '')}</td>
|
||||
<td className="py-2 pr-4">{String(p['category'] ?? '')}</td>
|
||||
<td className="py-2 pr-4">{String(p['status'] ?? '')}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw JSON fallback for content without sections */}
|
||||
{report.status === 'READY' && !sections && report.content && (
|
||||
<pre className="max-h-[600px] overflow-auto rounded-lg border bg-muted p-4 text-xs">
|
||||
{JSON.stringify(report.content, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
apps/web/app/[locale]/(dashboard)/dashboard/reports/new/page.tsx
Normal file
201
apps/web/app/[locale]/(dashboard)/dashboard/reports/new/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ArrowRight, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useGenerateReport } from '@/lib/hooks/use-reports';
|
||||
import type { ReportType } from '@/lib/reports-api';
|
||||
|
||||
type Step = 'type' | 'params' | 'confirm';
|
||||
|
||||
const PARAM_FIELDS: Record<ReportType, Array<{ key: string; label: string; placeholder: string; required: boolean }>> = {
|
||||
RESIDENTIAL_MARKET: [
|
||||
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
|
||||
{ key: 'period', label: 'Kỳ báo cáo', placeholder: '2026-Q1', required: true },
|
||||
],
|
||||
INDUSTRIAL_MARKET: [
|
||||
{ key: 'province', label: 'Tỉnh/TP', placeholder: 'Bình Dương', required: true },
|
||||
],
|
||||
DISTRICT_ANALYSIS: [
|
||||
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
|
||||
{ key: 'district', label: 'Quận/Huyện', placeholder: 'Quận 2', required: true },
|
||||
],
|
||||
INVESTMENT_FEASIBILITY: [
|
||||
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
|
||||
{ key: 'propertyType', label: 'Loại BĐS', placeholder: 'APARTMENT', required: true },
|
||||
],
|
||||
INDUSTRIAL_LOCATION: [
|
||||
{ key: 'province', label: 'Tỉnh', placeholder: 'Bình Dương', required: true },
|
||||
],
|
||||
PROPERTY_VALUATION: [
|
||||
{ key: 'propertyId', label: 'ID Bất động sản', placeholder: 'clx...', required: true },
|
||||
],
|
||||
PORTFOLIO: [
|
||||
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export default function NewReportPage() {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: trigger, isPending: isMutating } = useGenerateReport();
|
||||
|
||||
const [step, setStep] = React.useState<Step>('type');
|
||||
const [selectedType, setSelectedType] = React.useState<ReportType | null>(null);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [params, setParams] = React.useState<Record<string, string>>({});
|
||||
|
||||
const typeInfo = selectedType ? REPORT_TYPES.find((t) => t.value === selectedType) : null;
|
||||
const paramFields = selectedType ? PARAM_FIELDS[selectedType] : [];
|
||||
|
||||
const handleSelectType = (type: ReportType) => {
|
||||
setSelectedType(type);
|
||||
setParams({});
|
||||
const label = REPORT_TYPES.find((t) => t.value === type)?.label ?? type;
|
||||
setTitle(`Báo cáo ${label}`);
|
||||
setStep('params');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedType) return;
|
||||
const result = await trigger({
|
||||
type: selectedType,
|
||||
title,
|
||||
params,
|
||||
});
|
||||
if (result?.reportId) {
|
||||
router.push(`/dashboard/reports/${result.reportId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/reports">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Tạo báo cáo mới</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{step === 'type' && 'Bước 1: Chọn loại báo cáo'}
|
||||
{step === 'params' && 'Bước 2: Nhập thông tin'}
|
||||
{step === 'confirm' && 'Bước 3: Xác nhận'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Select type */}
|
||||
{step === 'type' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{REPORT_TYPES.map((rt) => (
|
||||
<button
|
||||
key={rt.value}
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:border-primary hover:bg-primary/5"
|
||||
onClick={() => handleSelectType(rt.value)}
|
||||
>
|
||||
<rt.icon className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium">{rt.label}</p>
|
||||
<p className="text-xs text-muted-foreground">Báo cáo {rt.label.toLowerCase()}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Parameters */}
|
||||
{step === 'params' && selectedType && (
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Tiêu đề báo cáo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive"> *</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={field.placeholder}
|
||||
value={params[field.key] ?? ''}
|
||||
onChange={(e) => setParams({ ...params, [field.key]: e.target.value })}
|
||||
className="w-full rounded-md border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button variant="outline" onClick={() => setStep('type')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Quay lại
|
||||
</Button>
|
||||
<Button onClick={() => setStep('confirm')}>
|
||||
Tiếp theo
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 'confirm' && selectedType && (
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
<h3 className="font-semibold">Xác nhận tạo báo cáo</h3>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Loại báo cáo:</span>
|
||||
<span className="font-medium">{typeInfo?.label}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tiêu đề:</span>
|
||||
<span className="font-medium">{title}</span>
|
||||
</div>
|
||||
{Object.entries(params)
|
||||
.filter(([, v]) => v)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button variant="outline" onClick={() => setStep('params')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Quay lại
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isMutating}>
|
||||
{isMutating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Đang tạo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Tạo báo cáo
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
apps/web/app/[locale]/(dashboard)/dashboard/reports/page.tsx
Normal file
100
apps/web/app/[locale]/(dashboard)/dashboard/reports/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { ReportCard } from '@/components/reports/report-card';
|
||||
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useDeleteReport, useReportsList } from '@/lib/hooks/use-reports';
|
||||
import type { ReportType } from '@/lib/reports-api';
|
||||
|
||||
export default function ReportsHubPage() {
|
||||
const [activeType, setActiveType] = React.useState<ReportType | undefined>();
|
||||
const { data, isLoading } = useReportsList({ type: activeType, limit: 20 });
|
||||
const { mutate: triggerDelete } = useDeleteReport();
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
triggerDelete(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Báo cáo AI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tạo và quản lý báo cáo phân tích thị trường bất động sản
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/reports/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Tạo báo cáo mới
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Type filter tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={activeType === undefined ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveType(undefined)}
|
||||
>
|
||||
Tất cả
|
||||
</Button>
|
||||
{REPORT_TYPES.map((rt) => (
|
||||
<Button
|
||||
key={rt.value}
|
||||
variant={activeType === rt.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveType(rt.value)}
|
||||
>
|
||||
<rt.icon className="mr-1 h-3.5 w-3.5" />
|
||||
{rt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reports list */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="ml-2">Đang tải...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.data.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.data.map((report) => (
|
||||
<ReportCard key={report.id} report={report} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.data.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<FileText className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium">Chưa có báo cáo nào</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Tạo báo cáo AI đầu tiên để phân tích thị trường bất động sản.
|
||||
</p>
|
||||
<Link href="/dashboard/reports/new" className="mt-4">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Tạo báo cáo mới
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.total > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hiển thị {data.data.length} / {data.total} báo cáo
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Bookmark,
|
||||
Bot,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Gem,
|
||||
Home,
|
||||
List,
|
||||
@@ -68,6 +69,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
label: t('dashboard.analytics'),
|
||||
items: [
|
||||
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
|
||||
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
|
||||
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
|
||||
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
|
||||
],
|
||||
@@ -93,6 +95,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
];
|
||||
|
||||
const secondaryNav: NavItem[] = [
|
||||
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
|
||||
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
|
||||
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
|
||||
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },
|
||||
|
||||
41
apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx
Normal file
41
apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ChuyenNhuongDetailClient } from '@/components/chuyen-nhuong/chuyen-nhuong-detail-client';
|
||||
import { fetchTransferListingById } from '@/lib/chuyen-nhuong-server';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const listing = await fetchTransferListingById(id);
|
||||
if (!listing) return { title: 'Không tìm thấy tin chuyển nhượng' };
|
||||
|
||||
const description = listing.description?.slice(0, 160) ??
|
||||
`${listing.title} — Chuyển nhượng tại ${listing.district}, ${listing.city}`;
|
||||
|
||||
return {
|
||||
title: `${listing.title} — Chuyển nhượng ${listing.district}`,
|
||||
description,
|
||||
openGraph: {
|
||||
title: listing.title,
|
||||
description,
|
||||
images: listing.media
|
||||
?.filter((m) => m.type === 'image')
|
||||
.slice(0, 1)
|
||||
.map((m) => ({ url: m.url })) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ChuyenNhuongDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const listing = await fetchTransferListingById(id);
|
||||
|
||||
if (!listing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ChuyenNhuongDetailClient listing={listing} />;
|
||||
}
|
||||
226
apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx
Normal file
226
apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { Package, Search, X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { TransferListingCard } from '@/components/chuyen-nhuong/transfer-listing-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
type SearchTransferListingsParams,
|
||||
type TransferCategory,
|
||||
type TransferListingStatus,
|
||||
CATEGORY_ICONS,
|
||||
CATEGORY_LABELS,
|
||||
STATUS_LABELS,
|
||||
} from '@/lib/chuyen-nhuong-api';
|
||||
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
const DISTRICTS = [
|
||||
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
||||
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
|
||||
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
||||
];
|
||||
|
||||
export default function ChuyenNhuongPage() {
|
||||
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
|
||||
page: 1,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
const [searchInput, setSearchInput] = React.useState('');
|
||||
|
||||
const { data, isLoading, isError } = useTransferListingsSearch(filters);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({ ...prev, q: searchInput.trim() || undefined, page: 1 }));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: TransferCategory | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, category, page: 1 }));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const updateFilter = (key: keyof SearchTransferListingsParams, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value || undefined, page: 1 }));
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters((prev) => ({ ...prev, page }));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchInput('');
|
||||
setFilters({ page: 1, limit: PAGE_SIZE });
|
||||
};
|
||||
|
||||
const hasFilters = filters.q || filters.category || filters.status || filters.district;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold md:text-3xl">Chuyển Nhượng</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Tìm kiếm nội thất, thiết bị và mặt bằng chuyển nhượng
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Tìm kiếm theo tên, quận, loại hình..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Tìm</Button>
|
||||
</form>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={filters.district ?? ''}
|
||||
onChange={(e) => updateFilter('district', e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Quận/Huyện"
|
||||
>
|
||||
<option value="">Quận/Huyện</option>
|
||||
{DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.status ?? ''}
|
||||
onChange={(e) => updateFilter('status', e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Trạng thái"
|
||||
>
|
||||
<option value="">Trạng thái</option>
|
||||
{(Object.entries(STATUS_LABELS) as [TransferListingStatus, string][]).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="gap-1">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Xóa bộ lọc
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="mt-4 flex gap-1 overflow-x-auto border-b" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={!filters.category}
|
||||
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
!filters.category
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => handleCategoryChange(undefined)}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{(Object.entries(CATEGORY_LABELS) as [TransferCategory, string][]).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={filters.category === key}
|
||||
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
filters.category === key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => handleCategoryChange(key)}
|
||||
>
|
||||
{CATEGORY_ICONS[key]} {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-72 animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Không thể tải danh sách chuyển nhượng. Vui lòng thử lại.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setFilters({ ...filters })}
|
||||
>
|
||||
Thử lại
|
||||
</Button>
|
||||
</div>
|
||||
) : data && data.data.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{data.total} tin chuyển nhượng được tìm thấy
|
||||
</p>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.data.map((listing) => (
|
||||
<TransferListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page === 1}
|
||||
onClick={() => handlePageChange((filters.page || 1) - 1)}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Trang {data.page} / {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= data.totalPages}
|
||||
onClick={() => handlePageChange((filters.page || 1) + 1)}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<Package className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="mt-4 text-lg font-medium">Không tìm thấy tin chuyển nhượng</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Thử thay đổi bộ lọc để tìm kiếm nhiều hơn
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { KhuCongNghiepDetailClient } from '@/components/khu-cong-nghiep/khu-cong-nghiep-detail-client';
|
||||
import { fetchIndustrialParkBySlug } from '@/lib/khu-cong-nghiep-server';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const park = await fetchIndustrialParkBySlug(slug);
|
||||
if (!park) return { title: 'Không tìm thấy khu công nghiệp' };
|
||||
|
||||
const description = park.description?.slice(0, 160) ??
|
||||
`${park.name} — KCN tại ${park.province}, diện tích ${park.totalAreaHa} ha, tỷ lệ lấp đầy ${park.occupancyRate}%`;
|
||||
|
||||
return {
|
||||
title: `${park.name} — Khu Công Nghiệp ${park.province}`,
|
||||
description,
|
||||
openGraph: {
|
||||
title: park.name,
|
||||
description,
|
||||
images: park.media
|
||||
?.filter((m) => m.type === 'image')
|
||||
.slice(0, 1)
|
||||
.map((m) => ({ url: m.url })) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KhuCongNghiepDetailPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const park = await fetchIndustrialParkBySlug(slug);
|
||||
|
||||
if (!park) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <KhuCongNghiepDetailClient park={park} />;
|
||||
}
|
||||
117
apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx
Normal file
117
apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Factory } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
|
||||
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep';
|
||||
import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function KhuCongNghiepPage() {
|
||||
const [filters, setFilters] = React.useState<SearchIndustrialParksParams>({
|
||||
page: 1,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
|
||||
|
||||
const handleFilterChange = (newFilters: SearchIndustrialParksParams) => {
|
||||
setFilters({ ...newFilters, limit: PAGE_SIZE });
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters((prev) => ({ ...prev, page }));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold md:text-3xl">Khu Công Nghiệp Việt Nam</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Tìm kiếm và so sánh các khu công nghiệp trên toàn quốc
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<ParkFilterBar params={filters} onChange={handleFilterChange} />
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-72 animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Không thể tải danh sách khu công nghiệp. Vui lòng thử lại.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setFilters({ ...filters })}
|
||||
>
|
||||
Thử lại
|
||||
</Button>
|
||||
</div>
|
||||
) : data && data.data.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{data.total} khu công nghiệp được tìm thấy
|
||||
</p>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.data.map((park) => (
|
||||
<ParkCard key={park.id} park={park} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page === 1}
|
||||
onClick={() => handlePageChange((filters.page || 1) - 1)}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Trang {data.page} / {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= data.totalPages}
|
||||
onClick={() => handlePageChange((filters.page || 1) + 1)}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<Factory className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="mt-4 text-lg font-medium">Không tìm thấy khu công nghiệp</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Thử thay đổi bộ lọc để tìm kiếm nhiều hơn
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,16 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
label: t('nav.projects'),
|
||||
isActive: pathname.includes('/du-an'),
|
||||
},
|
||||
{
|
||||
href: '/khu-cong-nghiep' as const,
|
||||
label: t('nav.industrialParks'),
|
||||
isActive: pathname.includes('/khu-cong-nghiep'),
|
||||
},
|
||||
{
|
||||
href: '/chuyen-nhuong' as const,
|
||||
label: t('nav.transfer'),
|
||||
isActive: pathname.includes('/chuyen-nhuong'),
|
||||
},
|
||||
{
|
||||
href: '/pricing' as const,
|
||||
label: t('nav.pricing'),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Eye,
|
||||
Heart,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Phone,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { TransferItemTable } from '@/components/chuyen-nhuong/transfer-item-table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
type TransferListingDetail,
|
||||
CATEGORY_ICONS,
|
||||
CATEGORY_LABELS,
|
||||
STATUS_LABELS,
|
||||
} from '@/lib/chuyen-nhuong-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChuyenNhuongDetailClientProps {
|
||||
listing: TransferListingDetail;
|
||||
}
|
||||
|
||||
function formatVND(value: string): string {
|
||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
||||
}
|
||||
|
||||
export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) {
|
||||
const statusColor =
|
||||
listing.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
|
||||
listing.status === 'RESERVED' ? 'bg-amber-100 text-amber-800' :
|
||||
listing.status === 'SOLD' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge className={cn('text-xs', statusColor)} variant="secondary">
|
||||
{STATUS_LABELS[listing.status]}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{CATEGORY_ICONS[listing.category]} {CATEGORY_LABELS[listing.category]}
|
||||
</Badge>
|
||||
{listing.isNegotiable && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs" variant="secondary">
|
||||
Thương lượng
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{listing.title}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{listing.address}, {listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||
<QuickStat
|
||||
label="Giá yêu cầu"
|
||||
value={formatVND(listing.askingPriceVND)}
|
||||
valueClassName="text-primary"
|
||||
/>
|
||||
{listing.aiEstimatePriceVND && (
|
||||
<QuickStat
|
||||
label="Giá AI ước tính"
|
||||
value={formatVND(listing.aiEstimatePriceVND)}
|
||||
/>
|
||||
)}
|
||||
{listing.areaM2 && (
|
||||
<QuickStat
|
||||
label="Diện tích"
|
||||
value={`${listing.areaM2} m\u00b2`}
|
||||
/>
|
||||
)}
|
||||
<QuickStat
|
||||
icon={<Eye className="h-5 w-5" />}
|
||||
label="Lượt xem"
|
||||
value={`${listing.viewCount}`}
|
||||
/>
|
||||
<QuickStat
|
||||
icon={<Heart className="h-5 w-5" />}
|
||||
label="Lượt lưu"
|
||||
value={`${listing.saveCount}`}
|
||||
/>
|
||||
<QuickStat
|
||||
icon={<MessageCircle className="h-5 w-5" />}
|
||||
label="Liên hệ"
|
||||
value={`${listing.inquiryCount}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Description */}
|
||||
{listing.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mô tả</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{listing.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Items table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Danh sách vật phẩm ({listing.items.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TransferItemTable items={listing.items} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business info */}
|
||||
{(listing.businessType || listing.monthlyRentVND || listing.remainingLeaseMo) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thông tin kinh doanh</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{listing.businessType && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Loại hình kinh doanh</span>
|
||||
<span className="font-medium">{listing.businessType}</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.monthlyRentVND && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tiền thuê hàng tháng</span>
|
||||
<span className="font-medium">{formatVND(listing.monthlyRentVND)}</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.depositMonths != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Cọc</span>
|
||||
<span className="font-medium">{listing.depositMonths} tháng</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.remainingLeaseMo != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Hợp đồng còn lại</span>
|
||||
<span className="font-medium">{listing.remainingLeaseMo} tháng</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.footTraffic && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Lưu lượng khách</span>
|
||||
<span className="font-medium">{listing.footTraffic}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Price card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Giá chuyển nhượng</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Giá yêu cầu</span>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{formatVND(listing.askingPriceVND)}
|
||||
</span>
|
||||
</div>
|
||||
{listing.aiEstimatePriceVND && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Giá AI ước tính</span>
|
||||
<span className="font-semibold">
|
||||
{formatVND(listing.aiEstimatePriceVND)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.aiConfidence != null && (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<span className="text-sm text-muted-foreground">Độ tin cậy AI</span>
|
||||
<span className="font-semibold">{Math.round(listing.aiConfidence * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.isNegotiable && (
|
||||
<p className="text-xs text-muted-foreground">Giá có thể thương lượng</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact card */}
|
||||
<Card className="lg:sticky lg:top-20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Thông tin liên hệ</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{listing.contactName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<User className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">{listing.contactName}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{listing.contactPhone ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">{listing.contactPhone}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!listing.contactName && !listing.contactPhone && (
|
||||
<p className="text-sm text-muted-foreground">Liên hệ qua hệ thống</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────
|
||||
|
||||
function QuickStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn('text-sm font-semibold', valueClassName)}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/web/components/chuyen-nhuong/transfer-item-table.tsx
Normal file
77
apps/web/components/chuyen-nhuong/transfer-item-table.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
type TransferItemData,
|
||||
CATEGORY_LABELS,
|
||||
CONDITION_COLORS,
|
||||
CONDITION_LABELS,
|
||||
} from '@/lib/chuyen-nhuong-api';
|
||||
|
||||
interface TransferItemTableProps {
|
||||
items: TransferItemData[];
|
||||
}
|
||||
|
||||
function formatVND(value: string): string {
|
||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
||||
}
|
||||
|
||||
export function TransferItemTable({ items }: TransferItemTableProps) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Chưa có danh sách vật phẩm.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">Tên</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Loại</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Tình trạng</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Thương hiệu</th>
|
||||
<th className="px-3 py-2 text-right font-medium">SL</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Giá yêu cầu</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Giá AI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b">
|
||||
<td className="px-3 py-2">
|
||||
<div>
|
||||
<p className="font-medium">{item.name}</p>
|
||||
{item.modelName && (
|
||||
<p className="text-xs text-muted-foreground">{item.modelName}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{CATEGORY_LABELS[item.category]}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={CONDITION_COLORS[item.condition]} variant="secondary">
|
||||
{CONDITION_LABELS[item.condition]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{item.brand ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">
|
||||
{formatVND(item.askingPriceVND)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground">
|
||||
{item.aiEstimatePriceVND ? (
|
||||
<span title={item.aiConfidence ? `Độ tin cậy: ${Math.round(item.aiConfidence * 100)}%` : undefined}>
|
||||
{formatVND(item.aiEstimatePriceVND)}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/web/components/chuyen-nhuong/transfer-listing-card.tsx
Normal file
102
apps/web/components/chuyen-nhuong/transfer-listing-card.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { Eye, MapPin, Package } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import {
|
||||
type TransferListingListItem,
|
||||
CATEGORY_ICONS,
|
||||
CATEGORY_LABELS,
|
||||
STATUS_LABELS,
|
||||
} from '@/lib/chuyen-nhuong-api';
|
||||
|
||||
interface TransferListingCardProps {
|
||||
listing: TransferListingListItem;
|
||||
}
|
||||
|
||||
function formatVND(value: string): string {
|
||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
||||
}
|
||||
|
||||
export function TransferListingCard({ listing }: TransferListingCardProps) {
|
||||
const statusColor =
|
||||
listing.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
|
||||
listing.status === 'RESERVED' ? 'bg-amber-100 text-amber-800' :
|
||||
listing.status === 'SOLD' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800';
|
||||
|
||||
return (
|
||||
<Link href={`/chuyen-nhuong/${listing.id}`}>
|
||||
<Card className="group h-full transition-shadow hover:shadow-lg">
|
||||
<CardContent className="p-5">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 font-semibold text-foreground group-hover:text-primary">
|
||||
{listing.title}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge className={statusColor} variant="secondary">
|
||||
{STATUS_LABELS[listing.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mb-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{CATEGORY_ICONS[listing.category]} {CATEGORY_LABELS[listing.category]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="line-clamp-1">{listing.district}, {listing.city}</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-3">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{formatVND(listing.askingPriceVND)}
|
||||
</p>
|
||||
{listing.isNegotiable && (
|
||||
<span className="text-xs text-muted-foreground">Thương lượng</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<div className="rounded-md bg-muted p-2 text-center">
|
||||
<div className="text-xs text-muted-foreground">Món</div>
|
||||
<div className="flex items-center justify-center gap-1 font-semibold">
|
||||
<Package className="h-3 w-3" />
|
||||
{listing.itemCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-2 text-center">
|
||||
<div className="text-xs text-muted-foreground">Lượt xem</div>
|
||||
<div className="flex items-center justify-center gap-1 font-semibold">
|
||||
<Eye className="h-3 w-3" />
|
||||
{listing.viewCount}
|
||||
</div>
|
||||
</div>
|
||||
{listing.areaM2 && (
|
||||
<div className="rounded-md bg-muted p-2 text-center">
|
||||
<div className="text-xs text-muted-foreground">Diện tích</div>
|
||||
<div className="font-semibold">{listing.areaM2} m²</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{listing.publishedAt && (
|
||||
<div className="border-t pt-3 text-xs text-muted-foreground">
|
||||
Đăng {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Building2,
|
||||
Calendar,
|
||||
Download,
|
||||
Factory,
|
||||
FileText,
|
||||
Globe,
|
||||
MapPin,
|
||||
Ruler,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { type IndustrialParkDetail,
|
||||
PARK_STATUS_COLORS,
|
||||
PARK_STATUS_LABELS,
|
||||
REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'infrastructure' | 'connectivity' | 'incentives' | 'tenants' | 'documents';
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'infrastructure', label: 'Hạ tầng' },
|
||||
{ key: 'connectivity', label: 'Kết nối giao thông' },
|
||||
{ key: 'incentives', label: 'Ưu đãi đầu tư' },
|
||||
{ key: 'tenants', label: 'Doanh nghiệp' },
|
||||
{ key: 'documents', label: 'Tài liệu' },
|
||||
];
|
||||
|
||||
interface KhuCongNghiepDetailClientProps {
|
||||
park: IndustrialParkDetail;
|
||||
}
|
||||
|
||||
export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientProps) {
|
||||
const [activeTab, setActiveTab] = React.useState<Tab>('infrastructure');
|
||||
|
||||
const occupancyColor =
|
||||
park.occupancyRate >= 90 ? 'text-red-600' :
|
||||
park.occupancyRate >= 70 ? 'text-amber-600' :
|
||||
'text-green-600';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge className={cn('text-xs', PARK_STATUS_COLORS[park.status])} variant="secondary">
|
||||
{PARK_STATUS_LABELS[park.status]}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{REGION_LABELS[park.region]}
|
||||
</Badge>
|
||||
{park.isVerified && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs" variant="secondary">
|
||||
Đã xác minh
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{park.name}</h1>
|
||||
{park.nameEn && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{park.nameEn}</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{park.address}, {park.district}, {park.province}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{park.developer}
|
||||
</span>
|
||||
{park.establishedYear && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Thành lập: {park.establishedYear}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||
<QuickStat
|
||||
icon={<Ruler className="h-5 w-5" />}
|
||||
label="Tổng diện tích"
|
||||
value={`${park.totalAreaHa.toLocaleString()} ha`}
|
||||
/>
|
||||
<QuickStat
|
||||
icon={<Factory className="h-5 w-5" />}
|
||||
label="DT cho thuê"
|
||||
value={`${park.leasableAreaHa.toLocaleString()} ha`}
|
||||
/>
|
||||
<QuickStat
|
||||
label="Tỷ lệ lấp đầy"
|
||||
value={`${park.occupancyRate}%`}
|
||||
valueClassName={occupancyColor}
|
||||
/>
|
||||
<QuickStat
|
||||
label="Còn trống"
|
||||
value={`${park.remainingAreaHa.toLocaleString()} ha`}
|
||||
/>
|
||||
<QuickStat
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label="Doanh nghiệp"
|
||||
value={`${park.tenantCount}`}
|
||||
/>
|
||||
<QuickStat
|
||||
label="Tin đăng"
|
||||
value={`${park.listingCount}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Description */}
|
||||
{park.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Giới thiệu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{park.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Target industries */}
|
||||
{park.targetIndustries.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ngành nghề thu hút</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{park.targetIndustries.map((industry) => (
|
||||
<Badge key={industry} variant="secondary">
|
||||
{industry}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Certifications */}
|
||||
{park.certifications && park.certifications.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chứng nhận</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{park.certifications.map((cert) => (
|
||||
<Badge key={cert} variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{cert}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div>
|
||||
<div className="flex gap-1 overflow-x-auto border-b" role="tablist">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
className={cn(
|
||||
'shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{activeTab === 'infrastructure' && <InfrastructureTab park={park} />}
|
||||
{activeTab === 'connectivity' && <ConnectivityTab park={park} />}
|
||||
{activeTab === 'incentives' && <IncentivesTab park={park} />}
|
||||
{activeTab === 'tenants' && <TenantsTab park={park} />}
|
||||
{activeTab === 'documents' && <DocumentsTab park={park} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Rent info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Giá thuê</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{park.landRentUsdM2Year != null ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Thuê đất</span>
|
||||
<span className="font-semibold text-primary">
|
||||
${park.landRentUsdM2Year}/m²/năm
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{park.rbfRentUsdM2Month != null ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Nhà xưởng xây sẵn</span>
|
||||
<span className="font-semibold">${park.rbfRentUsdM2Month}/m²/tháng</span>
|
||||
</div>
|
||||
) : null}
|
||||
{park.rbwRentUsdM2Month != null ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Nhà kho</span>
|
||||
<span className="font-semibold">${park.rbwRentUsdM2Month}/m²/tháng</span>
|
||||
</div>
|
||||
) : null}
|
||||
{park.managementFeeUsd != null ? (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<span className="text-sm text-muted-foreground">Phí quản lý</span>
|
||||
<span className="font-semibold">${park.managementFeeUsd}/m²/năm</span>
|
||||
</div>
|
||||
) : null}
|
||||
{park.landRentUsdM2Year == null &&
|
||||
park.rbfRentUsdM2Month == null &&
|
||||
park.rbwRentUsdM2Month == null && (
|
||||
<p className="text-sm text-muted-foreground">Liên hệ để biết giá thuê</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Developer / Operator */}
|
||||
<Card className="lg:sticky lg:top-20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Thông tin quản lý</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Chủ đầu tư</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">{park.developer}</p>
|
||||
</div>
|
||||
</div>
|
||||
{park.operator && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Đơn vị vận hành</p>
|
||||
<p className="mt-1 text-sm font-medium">{park.operator}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────
|
||||
|
||||
function QuickStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn('text-sm font-semibold', valueClassName)}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureTab({ park }: { park: IndustrialParkDetail }) {
|
||||
if (!park.infrastructure || Object.keys(park.infrastructure).length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Chưa cập nhật thông tin hạ tầng.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{Object.entries(park.infrastructure).map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg border p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground capitalize">{key.replace(/_/g, ' ')}</p>
|
||||
<p className="mt-1 text-sm">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectivityTab({ park }: { park: IndustrialParkDetail }) {
|
||||
if (!park.connectivity || Object.keys(park.connectivity).length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Chưa cập nhật thông tin kết nối giao thông.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(park.connectivity).map(([key, info]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground capitalize">{key.replace(/_/g, ' ')}</p>
|
||||
<p className="text-sm font-medium">{info.name}</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{info.distanceKm} km</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncentivesTab({ park }: { park: IndustrialParkDetail }) {
|
||||
if (!park.incentives || Object.keys(park.incentives).length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Chưa cập nhật thông tin ưu đãi.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(park.incentives).map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-sm font-medium capitalize">{key.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantsTab({ park }: { park: IndustrialParkDetail }) {
|
||||
if (!park.existingTenants || park.existingTenants.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Chưa cập nhật danh sách doanh nghiệp.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">Doanh nghiệp</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Quốc gia</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Ngành nghề</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{park.existingTenants.map((tenant) => (
|
||||
<tr key={tenant.name} className="border-b">
|
||||
<td className="px-3 py-2 font-medium">{tenant.name}</td>
|
||||
<td className="px-3 py-2">{tenant.country}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{tenant.industry}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentsTab({ park }: { park: IndustrialParkDetail }) {
|
||||
if (!park.documents || park.documents.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Chưa có tài liệu nào.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{park.documents.map((doc) => (
|
||||
<a
|
||||
key={doc.url}
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<FileText className="h-5 w-5 shrink-0 text-primary" />
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium">{doc.name}</p>
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/khu-cong-nghiep/park-card.tsx
Normal file
105
apps/web/components/khu-cong-nghiep/park-card.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { Building2, MapPin } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { type IndustrialParkListItem, PARK_STATUS_COLORS, PARK_STATUS_LABELS, REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
|
||||
|
||||
interface ParkCardProps {
|
||||
park: IndustrialParkListItem;
|
||||
}
|
||||
|
||||
export function ParkCard({ park }: ParkCardProps) {
|
||||
const occupancyColor =
|
||||
park.occupancyRate >= 90 ? 'text-red-600' :
|
||||
park.occupancyRate >= 70 ? 'text-amber-600' :
|
||||
'text-green-600';
|
||||
|
||||
return (
|
||||
<Link href={`/khu-cong-nghiep/${park.slug}`}>
|
||||
<Card className="group h-full transition-shadow hover:shadow-lg">
|
||||
<CardContent className="p-5">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-1 font-semibold text-foreground group-hover:text-primary">
|
||||
{park.name}
|
||||
</h3>
|
||||
{park.nameEn && (
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground">{park.nameEn}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={PARK_STATUS_COLORS[park.status]} variant="secondary">
|
||||
{PARK_STATUS_LABELS[park.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="line-clamp-1">{park.province} · {REGION_LABELS[park.region]}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<div className="text-xs text-muted-foreground">Diện tích</div>
|
||||
<div className="font-semibold">{park.totalAreaHa.toLocaleString()} ha</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<div className="text-xs text-muted-foreground">Lấp đầy</div>
|
||||
<div className={`font-semibold ${occupancyColor}`}>{park.occupancyRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<div className="text-xs text-muted-foreground">Còn trống</div>
|
||||
<div className="font-semibold">{park.remainingAreaHa.toLocaleString()} ha</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<div className="text-xs text-muted-foreground">Doanh nghiệp</div>
|
||||
<div className="font-semibold">{park.tenantCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rent info */}
|
||||
{park.landRentUsdM2Year && (
|
||||
<div className="mb-3 flex items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Thuê đất: </span>
|
||||
<span className="font-medium text-primary">${park.landRentUsdM2Year}/m²/năm</span>
|
||||
</div>
|
||||
{park.rbfRentUsdM2Month && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">NX: </span>
|
||||
<span className="font-medium">${park.rbfRentUsdM2Month}/m²/th</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industries */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{park.targetIndustries.slice(0, 3).map((industry) => (
|
||||
<Badge key={industry} variant="outline" className="text-xs">
|
||||
{industry}
|
||||
</Badge>
|
||||
))}
|
||||
{park.targetIndustries.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{park.targetIndustries.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-3 flex items-center gap-3 border-t pt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{park.developer}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
102
apps/web/components/khu-cong-nghiep/park-filter-bar.tsx
Normal file
102
apps/web/components/khu-cong-nghiep/park-filter-bar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { Search, X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { type IndustrialParkStatus, type SearchIndustrialParksParams, type VietnamRegion, PARK_STATUS_LABELS, REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
|
||||
|
||||
interface ParkFilterBarProps {
|
||||
params: SearchIndustrialParksParams;
|
||||
onChange: (params: SearchIndustrialParksParams) => void;
|
||||
}
|
||||
|
||||
const PROVINCES = [
|
||||
'Bắc Ninh', 'Bình Dương', 'Đồng Nai', 'Hà Nội', 'Hải Phòng', 'Hưng Yên',
|
||||
'Long An', 'Bà Rịa - Vũng Tàu', 'Bình Phước', 'Hải Dương', 'Nghệ An',
|
||||
'Quảng Nam', 'TP. Hồ Chí Minh',
|
||||
];
|
||||
|
||||
export function ParkFilterBar({ params, onChange }: ParkFilterBarProps) {
|
||||
const [searchInput, setSearchInput] = React.useState(params.q ?? '');
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onChange({ ...params, q: searchInput.trim() || undefined, page: 1 });
|
||||
};
|
||||
|
||||
const updateFilter = (key: keyof SearchIndustrialParksParams, value: string) => {
|
||||
onChange({ ...params, [key]: value || undefined, page: 1 });
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchInput('');
|
||||
onChange({ page: 1, limit: params.limit });
|
||||
};
|
||||
|
||||
const hasFilters = params.q || params.region || params.province || params.status;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search bar */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Tìm kiếm KCN theo tên, chủ đầu tư, tỉnh..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Tìm</Button>
|
||||
</form>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={params.region ?? ''}
|
||||
onChange={(e) => updateFilter('region', e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Vùng miền"
|
||||
>
|
||||
<option value="">Vùng miền</option>
|
||||
{(Object.entries(REGION_LABELS) as [VietnamRegion, string][]).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={params.province ?? ''}
|
||||
onChange={(e) => updateFilter('province', e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Tỉnh/TP"
|
||||
>
|
||||
<option value="">Tỉnh/TP</option>
|
||||
{PROVINCES.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={params.status ?? ''}
|
||||
onChange={(e) => updateFilter('status', e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Trạng thái"
|
||||
>
|
||||
<option value="">Trạng thái</option>
|
||||
{(Object.entries(PARK_STATUS_LABELS) as [IndustrialParkStatus, string][]).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="gap-1">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Xóa bộ lọc
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,
|
||||
}));
|
||||
179
apps/web/lib/chuyen-nhuong-api.ts
Normal file
179
apps/web/lib/chuyen-nhuong-api.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
export type TransferCategory = 'FURNITURE' | 'APPLIANCE' | 'OFFICE_EQUIPMENT' | 'KITCHEN' | 'PREMISES' | 'FULL_UNIT';
|
||||
export type TransferCondition = 'NEW' | 'LIKE_NEW' | 'GOOD' | 'FAIR' | 'WORN';
|
||||
export type TransferListingStatus = 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | 'RESERVED' | 'SOLD' | 'EXPIRED' | 'REJECTED';
|
||||
export type TransferPricingSource = 'MANUAL' | 'AI_ESTIMATED' | 'NEGOTIABLE';
|
||||
|
||||
export interface TransferListingListItem {
|
||||
id: string;
|
||||
sellerId: string;
|
||||
category: TransferCategory;
|
||||
status: TransferListingStatus;
|
||||
title: string;
|
||||
address: string;
|
||||
district: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
askingPriceVND: string; // BigInt serialized as string
|
||||
aiEstimatePriceVND: string | null;
|
||||
pricingSource: TransferPricingSource;
|
||||
isNegotiable: boolean;
|
||||
areaM2: number | null;
|
||||
media: { url: string; type: string; order: number; caption?: string }[] | null;
|
||||
viewCount: number;
|
||||
inquiryCount: number;
|
||||
publishedAt: string | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface TransferItemData {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
modelName: string | null;
|
||||
category: TransferCategory;
|
||||
condition: TransferCondition;
|
||||
purchaseYear: number | null;
|
||||
originalPriceVND: string | null;
|
||||
askingPriceVND: string;
|
||||
aiEstimatePriceVND: string | null;
|
||||
aiConfidence: number | null;
|
||||
quantity: number;
|
||||
dimensions: { widthCm?: number; heightCm?: number; depthCm?: number; weightKg?: number } | null;
|
||||
media: { url: string; type: string; order: number }[] | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface TransferListingDetail {
|
||||
id: string;
|
||||
sellerId: string;
|
||||
category: TransferCategory;
|
||||
status: TransferListingStatus;
|
||||
title: string;
|
||||
description: string | null;
|
||||
address: string;
|
||||
ward: string | null;
|
||||
district: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
askingPriceVND: string;
|
||||
aiEstimatePriceVND: string | null;
|
||||
aiConfidence: number | null;
|
||||
pricingSource: TransferPricingSource;
|
||||
isNegotiable: boolean;
|
||||
areaM2: number | null;
|
||||
monthlyRentVND: string | null;
|
||||
depositMonths: number | null;
|
||||
remainingLeaseMo: number | null;
|
||||
businessType: string | null;
|
||||
footTraffic: string | null;
|
||||
media: { url: string; type: string; order: number; caption?: string }[] | null;
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
inquiryCount: number;
|
||||
contactPhone: string | null;
|
||||
contactName: string | null;
|
||||
items: TransferItemData[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TransferStats {
|
||||
totalListings: number;
|
||||
totalValue: string;
|
||||
byCategory: { category: string; count: number; avgPrice: number }[];
|
||||
byDistrict: { district: string; count: number; avgPrice: number }[];
|
||||
byStatus: { status: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface SearchTransferListingsParams {
|
||||
q?: string;
|
||||
category?: TransferCategory;
|
||||
status?: TransferListingStatus;
|
||||
district?: string;
|
||||
city?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ─── Labels ─────────────────────────────────────────────
|
||||
|
||||
export const CATEGORY_LABELS: Record<TransferCategory, string> = {
|
||||
FURNITURE: 'Nội thất',
|
||||
APPLIANCE: 'Thiết bị gia dụng',
|
||||
OFFICE_EQUIPMENT: 'Thiết bị văn phòng',
|
||||
KITCHEN: 'Bếp & thiết bị',
|
||||
PREMISES: 'Mặt bằng',
|
||||
FULL_UNIT: 'Trọn bộ',
|
||||
};
|
||||
|
||||
export const CATEGORY_ICONS: Record<TransferCategory, string> = {
|
||||
FURNITURE: '🛋️',
|
||||
APPLIANCE: '🧊',
|
||||
OFFICE_EQUIPMENT: '🖥️',
|
||||
KITCHEN: '🍳',
|
||||
PREMISES: '🏪',
|
||||
FULL_UNIT: '🏠',
|
||||
};
|
||||
|
||||
export const CONDITION_LABELS: Record<TransferCondition, string> = {
|
||||
NEW: 'Mới',
|
||||
LIKE_NEW: 'Như mới',
|
||||
GOOD: 'Tốt',
|
||||
FAIR: 'Khá',
|
||||
WORN: 'Cũ',
|
||||
};
|
||||
|
||||
export const CONDITION_COLORS: Record<TransferCondition, string> = {
|
||||
NEW: 'bg-green-100 text-green-800',
|
||||
LIKE_NEW: 'bg-emerald-100 text-emerald-800',
|
||||
GOOD: 'bg-blue-100 text-blue-800',
|
||||
FAIR: 'bg-amber-100 text-amber-800',
|
||||
WORN: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<TransferListingStatus, string> = {
|
||||
DRAFT: 'Nháp',
|
||||
PENDING_REVIEW: 'Chờ duyệt',
|
||||
ACTIVE: 'Đang đăng',
|
||||
RESERVED: 'Đã giữ',
|
||||
SOLD: 'Đã bán',
|
||||
EXPIRED: 'Hết hạn',
|
||||
REJECTED: 'Từ chối',
|
||||
};
|
||||
|
||||
// ─── API Functions ──────────────────────────────────────
|
||||
|
||||
export const transferApi = {
|
||||
search: (params: SearchTransferListingsParams = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') query.append(key, String(value));
|
||||
});
|
||||
const qs = query.toString();
|
||||
return apiClient.get<PaginatedResult<TransferListingListItem>>(
|
||||
`/transfer/listings${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
apiClient.get<TransferListingDetail>(`/transfer/listings/${id}`),
|
||||
|
||||
getStats: () =>
|
||||
apiClient.get<TransferStats>('/transfer/stats'),
|
||||
};
|
||||
15
apps/web/lib/chuyen-nhuong-server.ts
Normal file
15
apps/web/lib/chuyen-nhuong-server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { TransferListingDetail } from './chuyen-nhuong-api';
|
||||
|
||||
const API_URL = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001/api/v1';
|
||||
|
||||
export async function fetchTransferListingById(id: string): Promise<TransferListingDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/transfer/listings/${id}`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<TransferListingDetail>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
32
apps/web/lib/hooks/use-chuyen-nhuong.ts
Normal file
32
apps/web/lib/hooks/use-chuyen-nhuong.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { transferApi, type SearchTransferListingsParams } from '@/lib/chuyen-nhuong-api';
|
||||
|
||||
export const transferKeys = {
|
||||
all: ['transfer'] as const,
|
||||
search: (params: SearchTransferListingsParams) => ['transfer', 'search', params] as const,
|
||||
detail: (id: string) => ['transfer', 'detail', id] as const,
|
||||
stats: () => ['transfer', 'stats'] as const,
|
||||
};
|
||||
|
||||
export function useTransferListingsSearch(params: SearchTransferListingsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: transferKeys.search(params),
|
||||
queryFn: () => transferApi.search(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferListingDetail(id: string) {
|
||||
return useQuery({
|
||||
queryKey: transferKeys.detail(id),
|
||||
queryFn: () => transferApi.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferStats() {
|
||||
return useQuery({
|
||||
queryKey: transferKeys.stats(),
|
||||
queryFn: () => transferApi.getStats(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
53
apps/web/lib/hooks/use-khu-cong-nghiep.ts
Normal file
53
apps/web/lib/hooks/use-khu-cong-nghiep.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
industrialApi,
|
||||
type SearchIndustrialParksParams,
|
||||
} from '@/lib/khu-cong-nghiep-api';
|
||||
|
||||
export const industrialKeys = {
|
||||
all: ['industrial'] as const,
|
||||
search: (params: SearchIndustrialParksParams) => ['industrial', 'search', params] as const,
|
||||
detail: (slug: string) => ['industrial', 'detail', slug] as const,
|
||||
stats: () => ['industrial', 'stats'] as const,
|
||||
market: () => ['industrial', 'market'] as const,
|
||||
compare: (ids: string[]) => ['industrial', 'compare', ids] as const,
|
||||
};
|
||||
|
||||
export function useIndustrialParksSearch(params: SearchIndustrialParksParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: industrialKeys.search(params),
|
||||
queryFn: () => industrialApi.search(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndustrialParkDetail(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: industrialKeys.detail(slug),
|
||||
queryFn: () => industrialApi.getBySlug(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndustrialStats() {
|
||||
return useQuery({
|
||||
queryKey: industrialKeys.stats(),
|
||||
queryFn: () => industrialApi.getStats(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndustrialMarket() {
|
||||
return useQuery({
|
||||
queryKey: industrialKeys.market(),
|
||||
queryFn: () => industrialApi.getMarket(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndustrialCompare(ids: string[]) {
|
||||
return useQuery({
|
||||
queryKey: industrialKeys.compare(ids),
|
||||
queryFn: () => industrialApi.compare(ids),
|
||||
enabled: ids.length >= 2,
|
||||
});
|
||||
}
|
||||
68
apps/web/lib/hooks/use-reports.ts
Normal file
68
apps/web/lib/hooks/use-reports.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listReports,
|
||||
getReport,
|
||||
getReportStatus,
|
||||
generateReport,
|
||||
deleteReport,
|
||||
type ReportType,
|
||||
} from '../reports-api';
|
||||
|
||||
export const reportKeys = {
|
||||
all: ['reports'] as const,
|
||||
list: (params?: { type?: ReportType; limit?: number; offset?: number }) =>
|
||||
['reports', 'list', params] as const,
|
||||
detail: (id: string) => ['reports', 'detail', id] as const,
|
||||
status: (id: string) => ['reports', 'status', id] as const,
|
||||
};
|
||||
|
||||
export function useReportsList(params?: {
|
||||
type?: ReportType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: reportKeys.list(params),
|
||||
queryFn: () => listReports(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReport(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: reportKeys.detail(id!),
|
||||
queryFn: () => getReport(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportStatus(id: string | null, shouldPoll = false) {
|
||||
return useQuery({
|
||||
queryKey: reportKeys.status(id!),
|
||||
queryFn: () => getReportStatus(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: shouldPoll ? 3000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateReport() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { type: ReportType; title: string; params: Record<string, unknown> }) =>
|
||||
generateReport(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reportKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteReport() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteReport(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reportKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
160
apps/web/lib/khu-cong-nghiep-api.ts
Normal file
160
apps/web/lib/khu-cong-nghiep-api.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
export type IndustrialParkStatus =
|
||||
| 'PLANNING'
|
||||
| 'UNDER_CONSTRUCTION'
|
||||
| 'OPERATIONAL'
|
||||
| 'FULL';
|
||||
|
||||
export type VietnamRegion = 'NORTH' | 'CENTRAL' | 'SOUTH';
|
||||
|
||||
export interface IndustrialParkListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
status: IndustrialParkStatus;
|
||||
province: string;
|
||||
region: VietnamRegion;
|
||||
totalAreaHa: number;
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
targetIndustries: string[];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface IndustrialParkDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
operator: string | null;
|
||||
status: IndustrialParkStatus;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
district: string;
|
||||
province: string;
|
||||
region: VietnamRegion;
|
||||
totalAreaHa: number;
|
||||
leasableAreaHa: number;
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
infrastructure: Record<string, string> | null;
|
||||
connectivity: Record<string, { name: string; distanceKm: number }> | null;
|
||||
incentives: Record<string, unknown> | null;
|
||||
targetIndustries: string[];
|
||||
existingTenants: { name: string; country: string; industry: string }[] | null;
|
||||
certifications: string[] | null;
|
||||
media: { url: string; type: string; caption?: string }[] | null;
|
||||
documents: { url: string; name: string }[] | null;
|
||||
description: string | null;
|
||||
descriptionEn: string | null;
|
||||
isVerified: boolean;
|
||||
listingCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IndustrialParkStats {
|
||||
totalParks: number;
|
||||
totalAreaHa: number;
|
||||
avgOccupancyRate: number;
|
||||
totalTenants: number;
|
||||
byRegion: { region: string; count: number; avgOccupancy: number }[];
|
||||
byStatus: { status: string; count: number }[];
|
||||
topProvinces: { province: string; count: number; avgRent: number | null }[];
|
||||
}
|
||||
|
||||
export interface IndustrialMarketData {
|
||||
totalParks: number;
|
||||
avgOccupancyRate: number;
|
||||
avgLandRentUsdM2: number | null;
|
||||
avgRbfRentUsdM2: number | null;
|
||||
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface SearchIndustrialParksParams {
|
||||
q?: string;
|
||||
province?: string;
|
||||
region?: VietnamRegion;
|
||||
status?: IndustrialParkStatus;
|
||||
minAreaHa?: number;
|
||||
maxRentUsdM2?: number;
|
||||
targetIndustry?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ─── Labels ─────────────────────────────────────────────
|
||||
|
||||
export const PARK_STATUS_LABELS: Record<IndustrialParkStatus, string> = {
|
||||
PLANNING: 'Quy hoạch',
|
||||
UNDER_CONSTRUCTION: 'Đang xây dựng',
|
||||
OPERATIONAL: 'Đang hoạt động',
|
||||
FULL: 'Đã lấp đầy',
|
||||
};
|
||||
|
||||
export const PARK_STATUS_COLORS: Record<IndustrialParkStatus, string> = {
|
||||
PLANNING: 'bg-blue-100 text-blue-800',
|
||||
UNDER_CONSTRUCTION: 'bg-amber-100 text-amber-800',
|
||||
OPERATIONAL: 'bg-green-100 text-green-800',
|
||||
FULL: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const REGION_LABELS: Record<VietnamRegion, string> = {
|
||||
NORTH: 'Miền Bắc',
|
||||
CENTRAL: 'Miền Trung',
|
||||
SOUTH: 'Miền Nam',
|
||||
};
|
||||
|
||||
// ─── API Functions ──────────────────────────────────────
|
||||
|
||||
export const industrialApi = {
|
||||
search: (params: SearchIndustrialParksParams = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') query.append(key, String(value));
|
||||
});
|
||||
const qs = query.toString();
|
||||
return apiClient.get<PaginatedResult<IndustrialParkListItem>>(
|
||||
`/industrial/parks${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
getBySlug: (slug: string) =>
|
||||
apiClient.get<IndustrialParkDetail>(`/industrial/parks/${slug}`),
|
||||
|
||||
compare: (ids: string[]) =>
|
||||
apiClient.post<IndustrialParkDetail[]>('/industrial/parks/compare', { ids }),
|
||||
|
||||
getStats: () =>
|
||||
apiClient.get<IndustrialParkStats>('/industrial/parks/stats'),
|
||||
|
||||
getMarket: () =>
|
||||
apiClient.get<IndustrialMarketData>('/industrial/market'),
|
||||
};
|
||||
15
apps/web/lib/khu-cong-nghiep-server.ts
Normal file
15
apps/web/lib/khu-cong-nghiep-server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IndustrialParkDetail } from './khu-cong-nghiep-api';
|
||||
|
||||
const API_URL = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001/api/v1';
|
||||
|
||||
export async function fetchIndustrialParkBySlug(slug: string): Promise<IndustrialParkDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/industrial/parks/${slug}`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<IndustrialParkDetail>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
78
apps/web/lib/reports-api.ts
Normal file
78
apps/web/lib/reports-api.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
export type ReportType =
|
||||
| 'RESIDENTIAL_MARKET'
|
||||
| 'INDUSTRIAL_MARKET'
|
||||
| 'DISTRICT_ANALYSIS'
|
||||
| 'INVESTMENT_FEASIBILITY'
|
||||
| 'INDUSTRIAL_LOCATION'
|
||||
| 'PROPERTY_VALUATION'
|
||||
| 'PORTFOLIO';
|
||||
|
||||
export type ReportStatus = 'GENERATING' | 'READY' | 'FAILED';
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
type: ReportType;
|
||||
title: string;
|
||||
params: Record<string, unknown>;
|
||||
content: Record<string, unknown> | null;
|
||||
pdfUrl: string | null;
|
||||
status: ReportStatus;
|
||||
errorMsg: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListReportsResponse {
|
||||
data: Report[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GenerateReportResponse {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
export interface ReportStatusResponse {
|
||||
id: string;
|
||||
status: ReportStatus;
|
||||
errorMsg: string | null;
|
||||
pdfUrl: string | null;
|
||||
}
|
||||
|
||||
// ─── API Calls ──────────────────────────────────────────
|
||||
|
||||
export function listReports(params?: {
|
||||
type?: ReportType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ListReportsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.set('offset', String(params.offset));
|
||||
const qs = searchParams.toString();
|
||||
return apiClient.get<ListReportsResponse>(`/reports${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export function getReport(id: string): Promise<Report> {
|
||||
return apiClient.get<Report>(`/reports/${id}`);
|
||||
}
|
||||
|
||||
export function getReportStatus(id: string): Promise<ReportStatusResponse> {
|
||||
return apiClient.get<ReportStatusResponse>(`/reports/${id}/status`);
|
||||
}
|
||||
|
||||
export function generateReport(data: {
|
||||
type: ReportType;
|
||||
title: string;
|
||||
params: Record<string, unknown>;
|
||||
}): Promise<GenerateReportResponse> {
|
||||
return apiClient.post<GenerateReportResponse>('/reports/generate', data);
|
||||
}
|
||||
|
||||
export function deleteReport(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/reports/${id}`);
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
"search": "Search",
|
||||
"pricing": "Pricing",
|
||||
"projects": "Projects",
|
||||
"industrialParks": "Industrial Parks",
|
||||
"transfer": "Transfer",
|
||||
"mainNav": "Main navigation",
|
||||
"dashboardNav": "Dashboard",
|
||||
"adminNav": "Administration",
|
||||
@@ -40,6 +42,7 @@
|
||||
"inquiries": "Inquiries",
|
||||
"leads": "Leads",
|
||||
"analytics": "Analytics",
|
||||
"reports": "AI Reports",
|
||||
"savedSearches": "Saved searches",
|
||||
"aiValuation": "AI Valuation",
|
||||
"profile": "Profile",
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"search": "Tìm kiếm",
|
||||
"pricing": "Bảng giá",
|
||||
"projects": "Dự án",
|
||||
"industrialParks": "Khu CN",
|
||||
"transfer": "Chuyển nhượng",
|
||||
"mainNav": "Điều hướng chính",
|
||||
"dashboardNav": "Bảng điều khiển",
|
||||
"adminNav": "Quản trị",
|
||||
@@ -40,6 +42,7 @@
|
||||
"inquiries": "Liên hệ",
|
||||
"leads": "Lead",
|
||||
"analytics": "Phân tích",
|
||||
"reports": "Báo cáo AI",
|
||||
"savedSearches": "Tìm kiếm đã lưu",
|
||||
"aiValuation": "Định giá AI",
|
||||
"profile": "Hồ sơ",
|
||||
|
||||
@@ -43,7 +43,7 @@ const nextConfig = {
|
||||
"style-src 'self' 'unsafe-inline' https://api.mapbox.com",
|
||||
"img-src 'self' data: blob: https://*.mapbox.com https://*.tiles.mapbox.com https:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com https://api.goodgo.vn${process.env.NODE_ENV !== 'production' ? ' http://localhost:3001 http://localhost:3011' : ''}`,
|
||||
`connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com https://api.goodgo.vn${process.env.NODE_ENV !== 'production' ? ' http://localhost:3001 http://localhost:3011 http://localhost:9000' : ''}`,
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:",
|
||||
"frame-ancestors 'none'",
|
||||
|
||||
Reference in New Issue
Block a user