feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow - Add PII field encryption middleware with AES-256-GCM and deterministic search hashes - Add agents, inquiries, and leads domain modules with entities, events, value objects - Add web dashboard pages for inquiries and leads with detail dialogs - Add 30+ component tests (valuation, charts, listings, search, providers, UI) - Add Prisma migrations for encryption hash columns and MFA TOTP support - Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes) - Update dependencies and lock file - Clean up obsolete exploration/QA docs, add audit documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const { mockGetMyInquiries, mockMarkAsRead } = vi.hoisted(() => ({
|
||||
mockGetMyInquiries: vi.fn(),
|
||||
mockMarkAsRead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/inquiries-api', () => ({
|
||||
inquiriesApi: {
|
||||
getMyInquiries: mockGetMyInquiries,
|
||||
getByListing: vi.fn(),
|
||||
markAsRead: mockMarkAsRead,
|
||||
},
|
||||
}));
|
||||
|
||||
import InquiriesPage from '../page';
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
const mockInquiries = {
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
listingId: 'listing-1',
|
||||
listingTitle: 'Bán căn hộ 2PN Quận 7',
|
||||
userId: 'user-1',
|
||||
userName: 'Nguyễn Văn A',
|
||||
userPhone: '0901234567',
|
||||
message: 'Tôi muốn xem căn hộ này cuối tuần',
|
||||
phone: null,
|
||||
isRead: false,
|
||||
createdAt: '2026-04-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
listingId: 'listing-2',
|
||||
listingTitle: 'Cho thuê nhà phố Quận 2',
|
||||
userId: 'user-2',
|
||||
userName: 'Trần Thị B',
|
||||
userPhone: '0912345678',
|
||||
message: 'Giá thuê có thương lượng được không?',
|
||||
phone: '0912345678',
|
||||
isRead: true,
|
||||
createdAt: '2026-04-09T14:30:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
describe('InquiriesPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetMyInquiries.mockResolvedValue(mockInquiries);
|
||||
});
|
||||
|
||||
it('renders the page title and description', async () => {
|
||||
render(<InquiriesPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Quản lý liên hệ')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Xem và phản hồi các yêu cầu tư vấn từ khách hàng'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stats cards', async () => {
|
||||
render(<InquiriesPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tổng liên hệ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Stats labels are inside CardDescription elements
|
||||
const statCards = screen.getAllByText('Tổng liên hệ');
|
||||
expect(statCards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders inquiry data after loading', async () => {
|
||||
render(<InquiriesPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Names should appear in both mobile and desktop views
|
||||
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Trần Thị B').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no inquiries', async () => {
|
||||
mockGetMyInquiries.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
render(<InquiriesPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chưa có liên hệ nào')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the read/unread filter', async () => {
|
||||
render(<InquiriesPage />, { wrapper: createWrapper() });
|
||||
|
||||
const select = screen.getByDisplayValue('Tất cả');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens detail dialog when clicking inquiry card', async () => {
|
||||
render(<InquiriesPage />, { wrapper: createWrapper() });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Click on a table row (tr elements have onClick handlers)
|
||||
const rows = document.querySelectorAll('tr[class*="cursor-pointer"]');
|
||||
if (rows[0]) {
|
||||
await user.click(rows[0] as HTMLElement);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chi tiết liên hệ')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
214
apps/web/app/[locale]/(dashboard)/inquiries/page.tsx
Normal file
214
apps/web/app/[locale]/(dashboard)/inquiries/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { InquiryDetailDialog } from '@/components/inquiries/inquiry-detail-dialog';
|
||||
import { InquiryRow, InquiryStatusBadge } from '@/components/inquiries/inquiry-row';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { useMyInquiries } from '@/lib/hooks/use-inquiries';
|
||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||
|
||||
type ReadFilter = 'all' | 'unread' | 'read';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [readFilter, setReadFilter] = React.useState<ReadFilter>('all');
|
||||
const [selectedInquiry, setSelectedInquiry] = React.useState<InquiryReadDto | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
const { data: result, isLoading: loading } = useMyInquiries({ page, limit: 20 });
|
||||
|
||||
// Client-side filter for read/unread since API doesn't support it directly
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!result) return [];
|
||||
if (readFilter === 'all') return result.data;
|
||||
if (readFilter === 'unread') return result.data.filter((i) => !i.isRead);
|
||||
return result.data.filter((i) => i.isRead);
|
||||
}, [result, readFilter]);
|
||||
|
||||
const stats = React.useMemo(() => {
|
||||
if (!result) return { total: 0, unread: 0, read: 0 };
|
||||
return {
|
||||
total: result.total,
|
||||
unread: result.data.filter((i) => !i.isRead).length,
|
||||
read: result.data.filter((i) => i.isRead).length,
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
const handleSelectInquiry = (inquiry: InquiryReadDto) => {
|
||||
setSelectedInquiry(inquiry);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Quản lý liên hệ</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Xem và phản hồi các yêu cầu tư vấn từ khách hàng
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tổng liên hệ</CardDescription>
|
||||
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Chưa đọc</CardDescription>
|
||||
<CardTitle className="text-xl text-blue-600">
|
||||
{loading ? '...' : stats.unread}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Đã đọc</CardDescription>
|
||||
<CardTitle className="text-xl text-green-600">
|
||||
{loading ? '...' : stats.read}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={readFilter}
|
||||
onChange={(e) => {
|
||||
setReadFilter(e.target.value as ReadFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="all">Tất cả</option>
|
||||
<option value="unread">Chưa đọc</option>
|
||||
<option value="read">Đã đọc</option>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredData.length} liên hệ
|
||||
</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>
|
||||
) : filteredData.length === 0 ? (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-4xl mb-3">📭</p>
|
||||
<p>Chưa có liên hệ nào</p>
|
||||
<p className="text-xs mt-1">
|
||||
Khi khách hàng gửi yêu cầu tư vấn, chúng sẽ xuất hiện ở đây
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{filteredData.map((inquiry) => (
|
||||
<Card
|
||||
key={inquiry.id}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => handleSelectInquiry(inquiry)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{inquiry.userName}</p>
|
||||
<p className="text-xs text-muted-foreground">{inquiry.userPhone}</p>
|
||||
</div>
|
||||
<InquiryStatusBadge isRead={inquiry.isRead} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground line-clamp-1">
|
||||
{inquiry.listingTitle}
|
||||
</p>
|
||||
<p className="mt-1 text-sm line-clamp-2">{inquiry.message}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{new Date(inquiry.createdAt).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</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">Tin đăng</th>
|
||||
<th className="hidden p-3 font-medium sm:table-cell">Nội dung</th>
|
||||
<th className="p-3 font-medium text-center">Trạng thái</th>
|
||||
<th className="p-3 font-medium text-right">Ngày gửi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredData.map((inquiry) => (
|
||||
<InquiryRow
|
||||
key={inquiry.id}
|
||||
inquiry={inquiry}
|
||||
onSelect={handleSelectInquiry}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<InquiryDetailDialog
|
||||
inquiry={selectedInquiry}
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setSelectedInquiry(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
|
||||
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
|
||||
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '➕' },
|
||||
{ href: '/inquiries' as const, label: t('dashboard.inquiries'), icon: '💬' },
|
||||
{ href: '/leads' as const, label: t('dashboard.leads'), icon: '🎯' },
|
||||
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
|
||||
{ href: '/dashboard/saved-searches' as const, label: t('dashboard.savedSearches'), icon: '🔖' },
|
||||
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
|
||||
|
||||
178
apps/web/app/[locale]/(dashboard)/leads/__tests__/leads.spec.tsx
Normal file
178
apps/web/app/[locale]/(dashboard)/leads/__tests__/leads.spec.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const { mockGetLeads, mockGetStats, mockCreate, mockUpdateStatus, mockDeleteLead } = vi.hoisted(() => ({
|
||||
mockGetLeads: vi.fn(),
|
||||
mockGetStats: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdateStatus: vi.fn(),
|
||||
mockDeleteLead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/leads-api', async () => {
|
||||
const actual = await vi.importActual('@/lib/leads-api');
|
||||
return {
|
||||
...actual,
|
||||
leadsApi: {
|
||||
create: mockCreate,
|
||||
getLeads: mockGetLeads,
|
||||
getStats: mockGetStats,
|
||||
updateStatus: mockUpdateStatus,
|
||||
delete: mockDeleteLead,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import LeadsPage from '../page';
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
const mockLeads = {
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
agentId: 'agent-1',
|
||||
name: 'Phạm Minh C',
|
||||
phone: '0903456789',
|
||||
email: 'pham.c@example.com',
|
||||
source: 'website',
|
||||
score: 85,
|
||||
notes: { text: 'Khách hàng VIP, quan tâm căn hộ cao cấp' },
|
||||
status: 'NEW' as const,
|
||||
createdAt: '2026-04-10T09:00:00Z',
|
||||
updatedAt: '2026-04-10T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
agentId: 'agent-1',
|
||||
name: 'Lê Văn D',
|
||||
phone: '0904567890',
|
||||
email: null,
|
||||
source: 'referral',
|
||||
score: 60,
|
||||
notes: null,
|
||||
status: 'CONTACTED' as const,
|
||||
createdAt: '2026-04-08T15:00:00Z',
|
||||
updatedAt: '2026-04-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const mockStatsData = {
|
||||
totalLeads: 10,
|
||||
byStatus: { NEW: 3, CONTACTED: 4, QUALIFIED: 2, CONVERTED: 1 },
|
||||
conversionRate: 10.0,
|
||||
avgScore: 72,
|
||||
};
|
||||
|
||||
describe('LeadsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetLeads.mockResolvedValue(mockLeads);
|
||||
mockGetStats.mockResolvedValue(mockStatsData);
|
||||
});
|
||||
|
||||
it('renders the page title and add button', async () => {
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Quản lý lead')).toBeInTheDocument();
|
||||
expect(screen.getByText('Thêm lead')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stats cards with data', async () => {
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tổng lead')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tỷ lệ chuyển đổi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Điểm TB')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lead mới')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Stats show the numbers
|
||||
expect(screen.getByText('10.0%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead data after loading', async () => {
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Names appear in both mobile card and desktop table views
|
||||
expect(screen.getAllByText('Phạm Minh C').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Lê Văn D').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no leads', async () => {
|
||||
mockGetLeads.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chưa có lead nào')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens create lead dialog', async () => {
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText('Thêm lead'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Thêm lead mới')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Tên khách hàng *')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Số điện thoại *')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the status filter', async () => {
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
|
||||
const select = screen.getByDisplayValue('Tất cả trạng thái');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens detail dialog when clicking a lead', async () => {
|
||||
render(<LeadsPage />, { wrapper: createWrapper() });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Phạm Minh C').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Click on a table row (tr elements have onClick handlers)
|
||||
const rows = document.querySelectorAll('tr[class*="cursor-pointer"]');
|
||||
if (rows[0]) {
|
||||
await user.click(rows[0] as HTMLElement);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chi tiết lead')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
291
apps/web/app/[locale]/(dashboard)/leads/page.tsx
Normal file
291
apps/web/app/[locale]/(dashboard)/leads/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
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">
|
||||
<p className="text-4xl mb-3">📋</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user