diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/reports/[id]/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/reports/[id]/page.tsx new file mode 100644 index 0000000..6dd1a1d --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/reports/[id]/page.tsx @@ -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; + 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 ( +
+ +
+ ); + } + + if (!report) { + return ( +
+

Không tìm thấy báo cáo.

+
+ ); + } + + const sections = (report.content as { sections?: Record })?.sections; + + return ( +
+ {/* Header */} +
+
+ + + +
+
+ + +
+

{report.title}

+

+ Tạo lúc {new Date(report.createdAt).toLocaleString('vi-VN')} +

+
+
+ + {report.status === 'READY' && ( +
+ {report.pdfUrl && ( + + + + )} + +
+ )} +
+ + {/* Generating state */} + {report.status === 'GENERATING' && ( +
+ +

Đang tạo báo cáo...

+

+ 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. +

+
+ )} + + {/* Failed state */} + {report.status === 'FAILED' && ( +
+

Tạo báo cáo thất bại

+ {report.errorMsg && ( +

{report.errorMsg}

+ )} +
+ )} + + {/* Report content */} + {report.status === 'READY' && sections && ( +
+ {/* Sidebar TOC */} + + + {/* Content sections */} +
+ {Object.entries(sections).map(([key, section]) => ( +
+

{section.title}

+ {section.content && ( +
+ {String(section.content)} +
+ )} + {section.charts && ( + >} /> + )} + {section.data != null && !section.charts && ( + >} /> + )} + {section.projects && Array.isArray(section.projects) && section.projects.length > 0 && ( +
+ + + + + + + + + + {section.projects.map((proj: unknown, i: number) => { + const p = proj as Record; + return ( + + + + + + ); + })} + +
TênLoạiTrạng thái
{String(p['name'] ?? '')}{String(p['category'] ?? '')}{String(p['status'] ?? '')}
+
+ )} +
+ ))} +
+
+ )} + + {/* Raw JSON fallback for content without sections */} + {report.status === 'READY' && !sections && report.content && ( +
+          {JSON.stringify(report.content, null, 2)}
+        
+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/reports/new/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/reports/new/page.tsx new file mode 100644 index 0000000..b896843 --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/reports/new/page.tsx @@ -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> = { + 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('type'); + const [selectedType, setSelectedType] = React.useState(null); + const [title, setTitle] = React.useState(''); + const [params, setParams] = React.useState>({}); + + 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 ( +
+ {/* Header */} +
+ + + +
+

Tạo báo cáo mới

+

+ {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'} +

+
+
+ + {/* Step 1: Select type */} + {step === 'type' && ( +
+ {REPORT_TYPES.map((rt) => ( + + ))} +
+ )} + + {/* Step 2: Parameters */} + {step === 'params' && selectedType && ( +
+
+ + 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" + /> +
+ + {paramFields.map((field) => ( +
+ + 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" + /> +
+ ))} + +
+ + +
+
+ )} + + {/* Step 3: Confirm */} + {step === 'confirm' && selectedType && ( +
+

Xác nhận tạo báo cáo

+ +
+
+ Loại báo cáo: + {typeInfo?.label} +
+
+ Tiêu đề: + {title} +
+ {Object.entries(params) + .filter(([, v]) => v) + .map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/reports/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/reports/page.tsx new file mode 100644 index 0000000..8f1496f --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/reports/page.tsx @@ -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(); + const { data, isLoading } = useReportsList({ type: activeType, limit: 20 }); + const { mutate: triggerDelete } = useDeleteReport(); + + const handleDelete = (id: string) => { + triggerDelete(id); + }; + + return ( +
+ {/* Header */} +
+
+

Báo cáo AI

+

+ Tạo và quản lý báo cáo phân tích thị trường bất động sản +

+
+ + + +
+ + {/* Type filter tabs */} +
+ + {REPORT_TYPES.map((rt) => ( + + ))} +
+ + {/* Reports list */} + {isLoading && ( +
+
+ Đang tải... +
+ )} + + {!isLoading && data && data.data.length > 0 && ( +
+ {data.data.map((report) => ( + + ))} +
+ )} + + {!isLoading && data && data.data.length === 0 && ( +
+ +

Chưa có báo cáo nào

+

+ Tạo báo cáo AI đầu tiên để phân tích thị trường bất động sản. +

+ + + +
+ )} + + {data && data.total > 0 && ( +

+ Hiển thị {data.data.length} / {data.total} báo cáo +

+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/(dashboard)/layout.tsx b/apps/web/app/[locale]/(dashboard)/layout.tsx index bcc3eaf..26e5be3 100644 --- a/apps/web/app/[locale]/(dashboard)/layout.tsx +++ b/apps/web/app/[locale]/(dashboard)/layout.tsx @@ -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 }, diff --git a/apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx b/apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx new file mode 100644 index 0000000..3900a46 --- /dev/null +++ b/apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx @@ -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 { + 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 ; +} diff --git a/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx b/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx new file mode 100644 index 0000000..fe725e2 --- /dev/null +++ b/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx @@ -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({ + 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 ( +
+ {/* Page header */} +
+

Chuyển Nhượng

+

+ Tìm kiếm nội thất, thiết bị và mặt bằng chuyển nhượng +

+
+ + {/* Search bar */} +
+
+
+ + setSearchInput(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* Filters */} +
+ + + + + {hasFilters && ( + + )} +
+
+ + {/* Category tabs */} +
+ + {(Object.entries(CATEGORY_LABELS) as [TransferCategory, string][]).map(([key, label]) => ( + + ))} +
+ + {/* Results */} +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : isError ? ( +
+

+ Không thể tải danh sách chuyển nhượng. Vui lòng thử lại. +

+ +
+ ) : data && data.data.length > 0 ? ( + <> +

+ {data.total} tin chuyển nhượng được tìm thấy +

+ +
+ {data.data.map((listing) => ( + + ))} +
+ + {/* Pagination */} + {data.totalPages > 1 && ( +
+ + + Trang {data.page} / {data.totalPages} + + +
+ )} + + ) : ( +
+ +

Không tìm thấy tin chuyển nhượng

+

+ Thử thay đổi bộ lọc để tìm kiếm nhiều hơn +

+
+ )} +
+
+ ); +} diff --git a/apps/web/app/[locale]/(public)/khu-cong-nghiep/[slug]/page.tsx b/apps/web/app/[locale]/(public)/khu-cong-nghiep/[slug]/page.tsx new file mode 100644 index 0000000..ad63eef --- /dev/null +++ b/apps/web/app/[locale]/(public)/khu-cong-nghiep/[slug]/page.tsx @@ -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 { + 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 ; +} diff --git a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx new file mode 100644 index 0000000..bd76e54 --- /dev/null +++ b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx @@ -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({ + 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 ( +
+ {/* Page header */} +
+

Khu Công Nghiệp Việt Nam

+

+ Tìm kiếm và so sánh các khu công nghiệp trên toàn quốc +

+
+ + {/* Filters */} + + + {/* Results */} +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : isError ? ( +
+

+ Không thể tải danh sách khu công nghiệp. Vui lòng thử lại. +

+ +
+ ) : data && data.data.length > 0 ? ( + <> +

+ {data.total} khu công nghiệp được tìm thấy +

+ +
+ {data.data.map((park) => ( + + ))} +
+ + {/* Pagination */} + {data.totalPages > 1 && ( +
+ + + Trang {data.page} / {data.totalPages} + + +
+ )} + + ) : ( +
+ +

Không tìm thấy khu công nghiệp

+

+ Thử thay đổi bộ lọc để tìm kiếm nhiều hơn +

+
+ )} +
+
+ ); +} diff --git a/apps/web/app/[locale]/(public)/layout.tsx b/apps/web/app/[locale]/(public)/layout.tsx index a35c32f..15807a7 100644 --- a/apps/web/app/[locale]/(public)/layout.tsx +++ b/apps/web/app/[locale]/(public)/layout.tsx @@ -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'), diff --git a/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx b/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx new file mode 100644 index 0000000..c7203bb --- /dev/null +++ b/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx @@ -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 ( +
+ {/* Header */} +
+
+ + {STATUS_LABELS[listing.status]} + + + {CATEGORY_ICONS[listing.category]} {CATEGORY_LABELS[listing.category]} + + {listing.isNegotiable && ( + + Thương lượng + + )} +
+

{listing.title}

+
+ + + {listing.address}, {listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city} + +
+
+ + {/* Quick stats */} +
+ + {listing.aiEstimatePriceVND && ( + + )} + {listing.areaM2 && ( + + )} + } + label="Lượt xem" + value={`${listing.viewCount}`} + /> + } + label="Lượt lưu" + value={`${listing.saveCount}`} + /> + } + label="Liên hệ" + value={`${listing.inquiryCount}`} + /> +
+ +
+ {/* Main content */} +
+ {/* Description */} + {listing.description && ( + + + Mô tả + + +

+ {listing.description} +

+
+
+ )} + + {/* Items table */} + + + Danh sách vật phẩm ({listing.items.length}) + + + + + + + {/* Business info */} + {(listing.businessType || listing.monthlyRentVND || listing.remainingLeaseMo) && ( + + + Thông tin kinh doanh + + + {listing.businessType && ( +
+ Loại hình kinh doanh + {listing.businessType} +
+ )} + {listing.monthlyRentVND && ( +
+ Tiền thuê hàng tháng + {formatVND(listing.monthlyRentVND)} +
+ )} + {listing.depositMonths != null && ( +
+ Cọc + {listing.depositMonths} tháng +
+ )} + {listing.remainingLeaseMo != null && ( +
+ Hợp đồng còn lại + {listing.remainingLeaseMo} tháng +
+ )} + {listing.footTraffic && ( +
+ Lưu lượng khách + {listing.footTraffic} +
+ )} +
+
+ )} +
+ + {/* Sidebar */} +
+ {/* Price card */} + + + Giá chuyển nhượng + + +
+ Giá yêu cầu + + {formatVND(listing.askingPriceVND)} + +
+ {listing.aiEstimatePriceVND && ( +
+ Giá AI ước tính + + {formatVND(listing.aiEstimatePriceVND)} + +
+ )} + {listing.aiConfidence != null && ( +
+ Độ tin cậy AI + {Math.round(listing.aiConfidence * 100)}% +
+ )} + {listing.isNegotiable && ( +

Giá có thể thương lượng

+ )} +
+
+ + {/* Contact card */} + + + Thông tin liên hệ + + + {listing.contactName ? ( +
+
+ +
+

{listing.contactName}

+
+ ) : null} + {listing.contactPhone ? ( +
+ +

{listing.contactPhone}

+
+ ) : null} + {!listing.contactName && !listing.contactPhone && ( +

Liên hệ qua hệ thống

+ )} +
+
+
+
+
+ ); +} + +// ─── Sub-components ──────────────────────────────────────── + +function QuickStat({ + icon, + label, + value, + valueClassName, +}: { + icon?: React.ReactNode; + label: string; + value: string; + valueClassName?: string; +}) { + return ( +
+ {icon &&
{icon}
} +
+

{label}

+

{value}

+
+
+ ); +} diff --git a/apps/web/components/chuyen-nhuong/transfer-item-table.tsx b/apps/web/components/chuyen-nhuong/transfer-item-table.tsx new file mode 100644 index 0000000..6ddf664 --- /dev/null +++ b/apps/web/components/chuyen-nhuong/transfer-item-table.tsx @@ -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

Chưa có danh sách vật phẩm.

; + } + + return ( +
+ + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
TênLoạiTình trạngThương hiệuSLGiá yêu cầuGiá AI
+
+

{item.name}

+ {item.modelName && ( +

{item.modelName}

+ )} +
+
+ {CATEGORY_LABELS[item.category]} + + + {CONDITION_LABELS[item.condition]} + + + {item.brand ?? '—'} + {item.quantity} + {formatVND(item.askingPriceVND)} + + {item.aiEstimatePriceVND ? ( + + {formatVND(item.aiEstimatePriceVND)} + + ) : '—'} +
+
+ ); +} diff --git a/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx b/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx new file mode 100644 index 0000000..82bab1e --- /dev/null +++ b/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx @@ -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 ( + + + + {/* Header */} +
+
+

+ {listing.title} +

+
+ + {STATUS_LABELS[listing.status]} + +
+ + {/* Category */} +
+ + {CATEGORY_ICONS[listing.category]} {CATEGORY_LABELS[listing.category]} + +
+ + {/* Location */} +
+ + {listing.district}, {listing.city} +
+ + {/* Price */} +
+

+ {formatVND(listing.askingPriceVND)} +

+ {listing.isNegotiable && ( + Thương lượng + )} +
+ + {/* Stats grid */} +
+
+
Món
+
+ + {listing.itemCount} +
+
+
+
Lượt xem
+
+ + {listing.viewCount} +
+
+ {listing.areaM2 && ( +
+
Diện tích
+
{listing.areaM2} m²
+
+ )} +
+ + {/* Footer */} + {listing.publishedAt && ( +
+ Đăng {new Date(listing.publishedAt).toLocaleDateString('vi-VN')} +
+ )} +
+
+ + ); +} diff --git a/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx b/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx new file mode 100644 index 0000000..c2cfd18 --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx @@ -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('infrastructure'); + + const occupancyColor = + park.occupancyRate >= 90 ? 'text-red-600' : + park.occupancyRate >= 70 ? 'text-amber-600' : + 'text-green-600'; + + return ( +
+ {/* Header */} +
+
+ + {PARK_STATUS_LABELS[park.status]} + + + {REGION_LABELS[park.region]} + + {park.isVerified && ( + + Đã xác minh + + )} +
+

{park.name}

+ {park.nameEn && ( +

{park.nameEn}

+ )} +
+ + + {park.address}, {park.district}, {park.province} + + + + {park.developer} + + {park.establishedYear && ( + + + Thành lập: {park.establishedYear} + + )} +
+
+ + {/* Quick stats */} +
+ } + label="Tổng diện tích" + value={`${park.totalAreaHa.toLocaleString()} ha`} + /> + } + label="DT cho thuê" + value={`${park.leasableAreaHa.toLocaleString()} ha`} + /> + + + } + label="Doanh nghiệp" + value={`${park.tenantCount}`} + /> + +
+ +
+ {/* Main content */} +
+ {/* Description */} + {park.description && ( + + + Giới thiệu + + +

+ {park.description} +

+
+
+ )} + + {/* Target industries */} + {park.targetIndustries.length > 0 && ( + + + Ngành nghề thu hút + + +
+ {park.targetIndustries.map((industry) => ( + + {industry} + + ))} +
+
+
+ )} + + {/* Certifications */} + {park.certifications && park.certifications.length > 0 && ( + + + Chứng nhận + + +
+ {park.certifications.map((cert) => ( + + + {cert} + + ))} +
+
+
+ )} + + {/* Tabs */} +
+
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {activeTab === 'infrastructure' && } + {activeTab === 'connectivity' && } + {activeTab === 'incentives' && } + {activeTab === 'tenants' && } + {activeTab === 'documents' && } +
+
+
+ + {/* Sidebar */} +
+ {/* Rent info */} + + + Giá thuê + + + {park.landRentUsdM2Year != null ? ( +
+ Thuê đất + + ${park.landRentUsdM2Year}/m²/năm + +
+ ) : null} + {park.rbfRentUsdM2Month != null ? ( +
+ Nhà xưởng xây sẵn + ${park.rbfRentUsdM2Month}/m²/tháng +
+ ) : null} + {park.rbwRentUsdM2Month != null ? ( +
+ Nhà kho + ${park.rbwRentUsdM2Month}/m²/tháng +
+ ) : null} + {park.managementFeeUsd != null ? ( +
+ Phí quản lý + ${park.managementFeeUsd}/m²/năm +
+ ) : null} + {park.landRentUsdM2Year == null && + park.rbfRentUsdM2Month == null && + park.rbwRentUsdM2Month == null && ( +

Liên hệ để biết giá thuê

+ )} +
+
+ + {/* Developer / Operator */} + + + Thông tin quản lý + + +
+

Chủ đầu tư

+
+
+ +
+

{park.developer}

+
+
+ {park.operator && ( +
+

Đơn vị vận hành

+

{park.operator}

+
+ )} +
+
+
+
+
+ ); +} + +// ─── Sub-components ──────────────────────────────────────── + +function QuickStat({ + icon, + label, + value, + valueClassName, +}: { + icon?: React.ReactNode; + label: string; + value: string; + valueClassName?: string; +}) { + return ( +
+ {icon &&
{icon}
} +
+

{label}

+

{value}

+
+
+ ); +} + +function InfrastructureTab({ park }: { park: IndustrialParkDetail }) { + if (!park.infrastructure || Object.keys(park.infrastructure).length === 0) { + return

Chưa cập nhật thông tin hạ tầng.

; + } + + return ( +
+ {Object.entries(park.infrastructure).map(([key, value]) => ( +
+

{key.replace(/_/g, ' ')}

+

{value}

+
+ ))} +
+ ); +} + +function ConnectivityTab({ park }: { park: IndustrialParkDetail }) { + if (!park.connectivity || Object.keys(park.connectivity).length === 0) { + return

Chưa cập nhật thông tin kết nối giao thông.

; + } + + return ( +
+ {Object.entries(park.connectivity).map(([key, info]) => ( +
+
+

{key.replace(/_/g, ' ')}

+

{info.name}

+
+ {info.distanceKm} km +
+ ))} +
+ ); +} + +function IncentivesTab({ park }: { park: IndustrialParkDetail }) { + if (!park.incentives || Object.keys(park.incentives).length === 0) { + return

Chưa cập nhật thông tin ưu đãi.

; + } + + return ( +
+ {Object.entries(park.incentives).map(([key, value]) => ( +
+
+ +

{key.replace(/_/g, ' ')}

+
+

+ {typeof value === 'string' ? value : JSON.stringify(value)} +

+
+ ))} +
+ ); +} + +function TenantsTab({ park }: { park: IndustrialParkDetail }) { + if (!park.existingTenants || park.existingTenants.length === 0) { + return

Chưa cập nhật danh sách doanh nghiệp.

; + } + + return ( +
+ + + + + + + + + + {park.existingTenants.map((tenant) => ( + + + + + + ))} + +
Doanh nghiệpQuốc giaNgành nghề
{tenant.name}{tenant.country}{tenant.industry}
+
+ ); +} + +function DocumentsTab({ park }: { park: IndustrialParkDetail }) { + if (!park.documents || park.documents.length === 0) { + return

Chưa có tài liệu nào.

; + } + + return ( +
+ {park.documents.map((doc) => ( + + +

{doc.name}

+ +
+ ))} +
+ ); +} diff --git a/apps/web/components/khu-cong-nghiep/park-card.tsx b/apps/web/components/khu-cong-nghiep/park-card.tsx new file mode 100644 index 0000000..0b8be3d --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/park-card.tsx @@ -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 ( + + + + {/* Header */} +
+
+

+ {park.name} +

+ {park.nameEn && ( +

{park.nameEn}

+ )} +
+ + {PARK_STATUS_LABELS[park.status]} + +
+ + {/* Location */} +
+ + {park.province} · {REGION_LABELS[park.region]} +
+ + {/* Stats grid */} +
+
+
Diện tích
+
{park.totalAreaHa.toLocaleString()} ha
+
+
+
Lấp đầy
+
{park.occupancyRate}%
+
+
+
Còn trống
+
{park.remainingAreaHa.toLocaleString()} ha
+
+
+
Doanh nghiệp
+
{park.tenantCount}
+
+
+ + {/* Rent info */} + {park.landRentUsdM2Year && ( +
+
+ Thuê đất: + ${park.landRentUsdM2Year}/m²/năm +
+ {park.rbfRentUsdM2Month && ( +
+ NX: + ${park.rbfRentUsdM2Month}/m²/th +
+ )} +
+ )} + + {/* Industries */} +
+ {park.targetIndustries.slice(0, 3).map((industry) => ( + + {industry} + + ))} + {park.targetIndustries.length > 3 && ( + + +{park.targetIndustries.length - 3} + + )} +
+ + {/* Footer */} +
+
+ + {park.developer} +
+
+
+
+ + ); +} diff --git a/apps/web/components/khu-cong-nghiep/park-filter-bar.tsx b/apps/web/components/khu-cong-nghiep/park-filter-bar.tsx new file mode 100644 index 0000000..b00843f --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/park-filter-bar.tsx @@ -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 ( +
+ {/* Search bar */} +
+
+ + setSearchInput(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* Filters */} +
+ + + + + + + {hasFilters && ( + + )} +
+
+ ); +} diff --git a/apps/web/components/reports/report-card.tsx b/apps/web/components/reports/report-card.tsx new file mode 100644 index 0000000..e20b120 --- /dev/null +++ b/apps/web/components/reports/report-card.tsx @@ -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 ( +
+
+
+
+ + +
+

{report.title}

+

+ + {date} +

+
+ +
+ {report.status === 'READY' && ( + + + + )} + {onDelete && ( + + )} +
+
+ + {report.status === 'READY' && ( + + Xem báo cáo + + )} + + {report.status === 'FAILED' && report.errorMsg && ( +

{report.errorMsg}

+ )} +
+ ); +} diff --git a/apps/web/components/reports/report-chart.tsx b/apps/web/components/reports/report-chart.tsx new file mode 100644 index 0000000..78c669a --- /dev/null +++ b/apps/web/components/reports/report-chart.tsx @@ -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 ( +
+

{title}

+ + {variant === 'bar' ? ( + + + + + [formatValue(value, unit), title]} + contentStyle={{ fontSize: 12 }} + /> + + + ) : ( + + + + + [formatValue(value, unit), title]} + contentStyle={{ fontSize: 12 }} + /> + + + )} + +
+ ); +} + +interface ReportChartsGridProps { + charts: Record; + labels?: Record; +} + +const DEFAULT_CHART_LABELS: Record = { + 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 = { + 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 ( +
+ {validCharts.map(([key, data]) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/reports/report-status-badge.tsx b/apps/web/components/reports/report-status-badge.tsx new file mode 100644 index 0000000..e5ae2ff --- /dev/null +++ b/apps/web/components/reports/report-status-badge.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; +import type { ReportStatus } from '@/lib/reports-api'; + +const statusConfig: Record = { + 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 ( + + + {config.label} + + ); +} diff --git a/apps/web/components/reports/report-type-badge.tsx b/apps/web/components/reports/report-type-badge.tsx new file mode 100644 index 0000000..7a182bc --- /dev/null +++ b/apps/web/components/reports/report-type-badge.tsx @@ -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 = { + 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 ( + + + {config.label} + + ); +} + +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, +})); diff --git a/apps/web/lib/chuyen-nhuong-api.ts b/apps/web/lib/chuyen-nhuong-api.ts new file mode 100644 index 0000000..77f82ba --- /dev/null +++ b/apps/web/lib/chuyen-nhuong-api.ts @@ -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 { + 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 = { + 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 = { + FURNITURE: '🛋️', + APPLIANCE: '🧊', + OFFICE_EQUIPMENT: '🖥️', + KITCHEN: '🍳', + PREMISES: '🏪', + FULL_UNIT: '🏠', +}; + +export const CONDITION_LABELS: Record = { + NEW: 'Mới', + LIKE_NEW: 'Như mới', + GOOD: 'Tốt', + FAIR: 'Khá', + WORN: 'Cũ', +}; + +export const CONDITION_COLORS: Record = { + 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 = { + 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>( + `/transfer/listings${qs ? `?${qs}` : ''}`, + ); + }, + + getById: (id: string) => + apiClient.get(`/transfer/listings/${id}`), + + getStats: () => + apiClient.get('/transfer/stats'), +}; diff --git a/apps/web/lib/chuyen-nhuong-server.ts b/apps/web/lib/chuyen-nhuong-server.ts new file mode 100644 index 0000000..ef42edc --- /dev/null +++ b/apps/web/lib/chuyen-nhuong-server.ts @@ -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 { + try { + const res = await fetch(`${API_URL}/transfer/listings/${id}`, { + next: { revalidate: 300 }, + }); + if (!res.ok) return null; + return res.json() as Promise; + } catch { + return null; + } +} diff --git a/apps/web/lib/hooks/use-chuyen-nhuong.ts b/apps/web/lib/hooks/use-chuyen-nhuong.ts new file mode 100644 index 0000000..3f86fe4 --- /dev/null +++ b/apps/web/lib/hooks/use-chuyen-nhuong.ts @@ -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, + }); +} diff --git a/apps/web/lib/hooks/use-khu-cong-nghiep.ts b/apps/web/lib/hooks/use-khu-cong-nghiep.ts new file mode 100644 index 0000000..5045daa --- /dev/null +++ b/apps/web/lib/hooks/use-khu-cong-nghiep.ts @@ -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, + }); +} diff --git a/apps/web/lib/hooks/use-reports.ts b/apps/web/lib/hooks/use-reports.ts new file mode 100644 index 0000000..844d844 --- /dev/null +++ b/apps/web/lib/hooks/use-reports.ts @@ -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 }) => + 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 }); + }, + }); +} diff --git a/apps/web/lib/khu-cong-nghiep-api.ts b/apps/web/lib/khu-cong-nghiep-api.ts new file mode 100644 index 0000000..cabfc29 --- /dev/null +++ b/apps/web/lib/khu-cong-nghiep-api.ts @@ -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 | null; + connectivity: Record | null; + incentives: Record | 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 { + 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 = { + 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 = { + 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 = { + 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>( + `/industrial/parks${qs ? `?${qs}` : ''}`, + ); + }, + + getBySlug: (slug: string) => + apiClient.get(`/industrial/parks/${slug}`), + + compare: (ids: string[]) => + apiClient.post('/industrial/parks/compare', { ids }), + + getStats: () => + apiClient.get('/industrial/parks/stats'), + + getMarket: () => + apiClient.get('/industrial/market'), +}; diff --git a/apps/web/lib/khu-cong-nghiep-server.ts b/apps/web/lib/khu-cong-nghiep-server.ts new file mode 100644 index 0000000..ae88ab1 --- /dev/null +++ b/apps/web/lib/khu-cong-nghiep-server.ts @@ -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 { + try { + const res = await fetch(`${API_URL}/industrial/parks/${slug}`, { + next: { revalidate: 300 }, + }); + if (!res.ok) return null; + return res.json() as Promise; + } catch { + return null; + } +} diff --git a/apps/web/lib/reports-api.ts b/apps/web/lib/reports-api.ts new file mode 100644 index 0000000..a5a7c98 --- /dev/null +++ b/apps/web/lib/reports-api.ts @@ -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; + content: Record | 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 { + 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(`/reports${qs ? `?${qs}` : ''}`); +} + +export function getReport(id: string): Promise { + return apiClient.get(`/reports/${id}`); +} + +export function getReportStatus(id: string): Promise { + return apiClient.get(`/reports/${id}/status`); +} + +export function generateReport(data: { + type: ReportType; + title: string; + params: Record; +}): Promise { + return apiClient.post('/reports/generate', data); +} + +export function deleteReport(id: string): Promise { + return apiClient.delete(`/reports/${id}`); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index be76c4d..b135671 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 13da694..04a3542 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -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ơ", diff --git a/apps/web/next.config.js b/apps/web/next.config.js index a512336..0c2190e 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -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'",