- Rewrite prisma/seed.ts to populate all 27 models with realistic Vietnamese real estate data (8 users with login, 10 properties, 10 listings, orders, payments, reviews, notifications, etc.) - Replace all emoji icons with Lucide React SVG icons across frontend for consistent rendering, sizing, and accessibility - Redesign dashboard nav: grouped sidebar with section headers, primary/secondary split on desktop, icon-only secondary items - Replace language switcher flag emoji with Globe icon - Replace SVG theme toggle with Lucide Moon/Sun icons - Fix API startup: graceful fallback for Sentry profiling, Google OAuth, and Zalo OAuth when credentials are not configured - Relax rate limiting in development mode (10k req/min) - Fix listings API to include media[] array in search response - Add optional chaining for property.media across frontend components - Update OAuth strategy tests to match graceful fallback behavior Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { ClipboardList } from 'lucide-react';
|
|
import * as React from 'react';
|
|
import { CreateLeadDialog } from '@/components/leads/create-lead-dialog';
|
|
import { LeadDetailDialog } from '@/components/leads/lead-detail-dialog';
|
|
import { LeadStatusBadge } from '@/components/leads/lead-status-badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Select } from '@/components/ui/select';
|
|
import { useLeads, useLeadStats } from '@/lib/hooks/use-leads';
|
|
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
|
|
|
function getSourceLabel(source: string): string {
|
|
const found = LEAD_SOURCES.find((s) => s.value === source);
|
|
return found?.label ?? source;
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleDateString('vi-VN', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
export default function LeadsPage() {
|
|
const [page, setPage] = React.useState(1);
|
|
const [statusFilter, setStatusFilter] = React.useState<LeadStatus | ''>('');
|
|
const [createOpen, setCreateOpen] = React.useState(false);
|
|
const [selectedLead, setSelectedLead] = React.useState<LeadReadDto | null>(null);
|
|
const [detailOpen, setDetailOpen] = React.useState(false);
|
|
|
|
const searchParams = React.useMemo(() => {
|
|
const params: { page: number; limit: number; status?: LeadStatus } = { page, limit: 20 };
|
|
if (statusFilter) params.status = statusFilter;
|
|
return params;
|
|
}, [page, statusFilter]);
|
|
|
|
const { data: result, isLoading: loading } = useLeads(searchParams);
|
|
const { data: stats, isLoading: statsLoading } = useLeadStats();
|
|
|
|
const handleSelectLead = (lead: LeadReadDto) => {
|
|
setSelectedLead(lead);
|
|
setDetailOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Quản lý lead</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Theo dõi và chuyển đổi khách hàng tiềm năng
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setCreateOpen(true)}>Thêm lead</Button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Tổng lead</CardDescription>
|
|
<CardTitle className="text-xl">
|
|
{statsLoading ? '...' : stats?.totalLeads ?? 0}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Tỷ lệ chuyển đổi</CardDescription>
|
|
<CardTitle className="text-xl text-green-600">
|
|
{statsLoading ? '...' : `${(stats?.conversionRate ?? 0).toFixed(1)}%`}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Điểm TB</CardDescription>
|
|
<CardTitle className="text-xl text-blue-600">
|
|
{statsLoading ? '...' : stats?.avgScore !== null ? stats?.avgScore?.toFixed(0) : 'N/A'}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Lead mới</CardDescription>
|
|
<CardTitle className="text-xl text-yellow-600">
|
|
{statsLoading ? '...' : stats?.byStatus?.['NEW'] ?? 0}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Status breakdown */}
|
|
{stats && !statsLoading && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(stats.byStatus).map(([status, count]) => {
|
|
const config = LEAD_STATUSES[status as LeadStatus];
|
|
if (!config || count === 0) return null;
|
|
return (
|
|
<button
|
|
key={status}
|
|
onClick={() => {
|
|
setStatusFilter(status === statusFilter ? '' : (status as LeadStatus));
|
|
setPage(1);
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors hover:bg-accent ${
|
|
status === statusFilter ? 'bg-accent border-primary' : ''
|
|
}`}
|
|
>
|
|
<LeadStatusBadge status={status as LeadStatus} />
|
|
<span className="text-muted-foreground">{count}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-3">
|
|
<Select
|
|
value={statusFilter}
|
|
onChange={(e) => {
|
|
setStatusFilter(e.target.value as LeadStatus | '');
|
|
setPage(1);
|
|
}}
|
|
className="w-44"
|
|
>
|
|
<option value="">Tất cả trạng thái</option>
|
|
{Object.entries(LEAD_STATUSES).map(([value, { label }]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
{result && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{result.total} lead
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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">
|
|
<ClipboardList className="h-10 w-10 text-muted-foreground mb-3" aria-hidden="true" />
|
|
<p>Chưa có lead nào</p>
|
|
<Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
|
|
Thêm lead đầu tiên
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Mobile card view */}
|
|
<div className="space-y-3 sm:hidden">
|
|
{result.data.map((lead) => (
|
|
<Card
|
|
key={lead.id}
|
|
className="cursor-pointer transition-shadow hover:shadow-md"
|
|
onClick={() => handleSelectLead(lead)}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">{lead.name}</p>
|
|
<p className="text-xs text-muted-foreground">{lead.phone}</p>
|
|
</div>
|
|
<LeadStatusBadge status={lead.status} />
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<span>{getSourceLabel(lead.source)}</span>
|
|
{lead.score !== null && <span>Điểm: {lead.score}</span>}
|
|
<span>{formatDate(lead.createdAt)}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Desktop table view */}
|
|
<Card className="hidden sm:block">
|
|
<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">Khách hàng</th>
|
|
<th className="p-3 font-medium">Nguồn</th>
|
|
<th className="p-3 font-medium text-center">Điểm</th>
|
|
<th className="p-3 font-medium text-center">Trạng thái</th>
|
|
<th className="p-3 font-medium text-right">Ngày tạo</th>
|
|
<th className="p-3 font-medium text-right">Cập nhật</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{result.data.map((lead) => (
|
|
<tr
|
|
key={lead.id}
|
|
className="border-b last:border-0 cursor-pointer transition-colors hover:bg-accent/50"
|
|
onClick={() => handleSelectLead(lead)}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="font-medium">{lead.name}</span>
|
|
<span className="text-xs text-muted-foreground">{lead.phone}</span>
|
|
{lead.email && (
|
|
<span className="text-xs text-muted-foreground">{lead.email}</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{getSourceLabel(lead.source)}
|
|
</td>
|
|
<td className="p-3 text-center">
|
|
{lead.score !== null ? (
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span className="font-medium">{lead.score}</span>
|
|
<div className="h-1 w-12 rounded-full bg-muted">
|
|
<div
|
|
className="h-1 rounded-full bg-primary"
|
|
style={{ width: `${lead.score}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
<td className="p-3 text-center">
|
|
<LeadStatusBadge status={lead.status} />
|
|
</td>
|
|
<td className="p-3 text-right text-xs text-muted-foreground">
|
|
{formatDate(lead.createdAt)}
|
|
</td>
|
|
<td className="p-3 text-right text-xs text-muted-foreground">
|
|
{formatDate(lead.updatedAt)}
|
|
</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={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
Trước
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Trang {result.page} / {result.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= result.totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Tiếp
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dialogs */}
|
|
<CreateLeadDialog open={createOpen} onOpenChange={setCreateOpen} />
|
|
<LeadDetailDialog
|
|
lead={selectedLead}
|
|
open={detailOpen}
|
|
onOpenChange={(open) => {
|
|
setDetailOpen(open);
|
|
if (!open) setSelectedLead(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|