feat(web): build agent dashboard with analytics charts and listing management
- Dashboard overview: stats cards (listings, views, inquiries, market avg price), Recharts bar chart for district pricing, recent listings feed with engagement metrics - Analytics page: tabbed layout (overview/trends/districts), interactive bar chart for district comparison, line chart for price trend over quarters with dual Y-axis, clickable heatmap cards - Listings management: grid/table view toggle, status filter, stats summary cards, table view with thumbnails and engagement data Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,35 +2,57 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||
import {
|
||||
listingsApi,
|
||||
type ListingDetail,
|
||||
type ListingStatus,
|
||||
type PaginatedResult,
|
||||
} from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
function formatPrice(priceVND: string): string {
|
||||
const num = Number(priceVND);
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
|
||||
return num.toLocaleString('vi-VN');
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'table';
|
||||
|
||||
export default function ListingsPage() {
|
||||
const { tokens } = useAuthStore();
|
||||
const [result, setResult] = React.useState<PaginatedResult<ListingDetail> | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
|
||||
const [filters, setFilters] = React.useState({
|
||||
transactionType: '',
|
||||
propertyType: '',
|
||||
status: '' as string,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchListings = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = { page: filters.page, limit: 12 };
|
||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||
if (filters.status) params['status'] = filters.status;
|
||||
|
||||
listingsApi
|
||||
.search(params)
|
||||
@@ -39,23 +61,78 @@ export default function ListingsPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [filters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchListings();
|
||||
}, [fetchListings]);
|
||||
|
||||
// Stats from current page data
|
||||
const stats = React.useMemo(() => {
|
||||
if (!result) return { total: 0, active: 0, pending: 0, views: 0 };
|
||||
return {
|
||||
total: result.total,
|
||||
active: result.data.filter((l) => l.status === 'ACTIVE').length,
|
||||
pending: result.data.filter((l) => l.status === 'PENDING_REVIEW').length,
|
||||
views: result.data.reduce((s, l) => s + l.viewCount, 0),
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-bold">Tin đăng</h1>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Quan ly tin dang</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quan ly, theo doi va cap nhat cac tin dang cua ban
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/listings/new">
|
||||
<Button>Đăng tin mới</Button>
|
||||
<Button>Dang tin moi</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tong tin dang</CardDescription>
|
||||
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Dang hoat dong</CardDescription>
|
||||
<CardTitle className="text-xl text-green-600">
|
||||
{loading ? '...' : stats.active}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Cho duyet</CardDescription>
|
||||
<CardTitle className="text-xl text-yellow-600">
|
||||
{loading ? '...' : stats.pending}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tong luot xem</CardDescription>
|
||||
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters + View Toggle */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={filters.transactionType}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))
|
||||
}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="">Tất cả giao dịch</option>
|
||||
<option value="">Tat ca giao dich</option>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
@@ -64,108 +141,217 @@ export default function ListingsPage() {
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.propertyType}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))
|
||||
}
|
||||
className="w-44"
|
||||
>
|
||||
<option value="">Tất cả loại BĐS</option>
|
||||
<option value="">Tat ca loai BDS</option>
|
||||
{PROPERTY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="">Tat ca trang thai</option>
|
||||
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div className="ml-auto flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
Luoi
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'table' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('table')}
|
||||
>
|
||||
Bang
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listing grid */}
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex min-h-[300px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||
<p>Chưa có tin đăng nào</p>
|
||||
<p>Chua co tin dang nao</p>
|
||||
<Link href="/listings/new" className="mt-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Đăng tin đầu tiên
|
||||
Dang tin dau tien
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{result.data.map((listing) => (
|
||||
<Link key={listing.id} href={`/listings/${listing.id}`}>
|
||||
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className="relative aspect-[4/3] bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
<img
|
||||
src={listing.property.media[0]?.url}
|
||||
alt={listing.property.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Chưa có ảnh
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2">
|
||||
<ListingStatusBadge status={listing.status} />
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{result.data.map((listing) => (
|
||||
<Link key={listing.id} href={`/listings/${listing.id}`}>
|
||||
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className="relative aspect-[4/3] bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
<img
|
||||
src={listing.property.media[0]?.url}
|
||||
alt={listing.property.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Chua co anh
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2">
|
||||
<ListingStatusBadge status={listing.status} />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{formatPrice(listing.priceVND)} VNĐ
|
||||
</p>
|
||||
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||
{listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{formatPrice(listing.priceVND)} VND
|
||||
</p>
|
||||
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||
{listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.areaM2} m2
|
||||
</Badge>
|
||||
{listing.property.bedrooms != null && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.areaM2} m²
|
||||
{listing.property.bedrooms} PN
|
||||
</Badge>
|
||||
{listing.property.bedrooms != null && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.bedrooms} PN
|
||||
</Badge>
|
||||
)}
|
||||
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.bathrooms} PT
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Trang {result.page} / {result.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page >= result.totalPages}
|
||||
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
|
||||
>
|
||||
Tiếp
|
||||
</Button>
|
||||
)}
|
||||
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.bathrooms} PT
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{listing.viewCount} luot xem</span>
|
||||
<span>{listing.inquiryCount} lien he</span>
|
||||
<span>{listing.saveCount} da luu</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Table View */
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="p-3 font-medium">Tin dang</th>
|
||||
<th className="p-3 font-medium">Loai</th>
|
||||
<th className="p-3 font-medium text-right">Gia</th>
|
||||
<th className="p-3 font-medium text-right">Dien tich</th>
|
||||
<th className="p-3 font-medium text-center">Trang thai</th>
|
||||
<th className="p-3 font-medium text-right">Luot xem</th>
|
||||
<th className="p-3 font-medium text-right">Lien he</th>
|
||||
<th className="p-3 font-medium text-right">Ngay dang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.data.map((listing) => (
|
||||
<tr
|
||||
key={listing.id}
|
||||
className="border-b last:border-0 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/listings/${listing.id}`}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<div className="h-10 w-14 flex-shrink-0 overflow-hidden rounded bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
<img
|
||||
src={listing.property.media[0]?.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium group-hover:text-primary">
|
||||
{listing.property.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{listing.property.propertyType}
|
||||
</td>
|
||||
<td className="p-3 text-right font-medium text-primary">
|
||||
{formatPrice(listing.priceVND)}
|
||||
</td>
|
||||
<td className="p-3 text-right">{listing.property.areaM2} m2</td>
|
||||
<td className="p-3 text-center">
|
||||
<ListingStatusBadge status={listing.status} />
|
||||
</td>
|
||||
<td className="p-3 text-right">{listing.viewCount}</td>
|
||||
<td className="p-3 text-right">{listing.inquiryCount}</td>
|
||||
<td className="p-3 text-right text-xs text-muted-foreground">
|
||||
{formatDate(listing.publishedAt ?? listing.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{result && result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
|
||||
>
|
||||
Truoc
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Trang {result.page} / {result.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page >= result.totalPages}
|
||||
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
|
||||
>
|
||||
Tiep
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user