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'),
|
||||
|
||||
Reference in New Issue
Block a user