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:
103
apps/web/components/leads/__tests__/create-lead-dialog.spec.tsx
Normal file
103
apps/web/components/leads/__tests__/create-lead-dialog.spec.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CreateLeadDialog } from '../create-lead-dialog';
|
||||
|
||||
// Mock the hook
|
||||
const mockMutate = vi.fn();
|
||||
vi.mock('@/lib/hooks/use-leads', () => ({
|
||||
useCreateLead: () => ({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Dialog components with simplified versions
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-content" className={className}>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('CreateLeadDialog', () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
});
|
||||
|
||||
it('renders dialog when open', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Thêm lead mới')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<CreateLeadDialog open={false} onOpenChange={vi.fn()} />);
|
||||
expect(screen.queryByText('Thêm lead mới')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders customer name input', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Tên khách hàng *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders phone input', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Số điện thoại *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email input', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders source select', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Nguồn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders score input', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Điểm (0-100)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notes textarea', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Ghi chú')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cancel and submit buttons', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Hủy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tạo lead')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOpenChange when cancel clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<CreateLeadDialog open={true} onOpenChange={onOpenChange} />);
|
||||
|
||||
await user.click(screen.getByText('Hủy'));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('calls mutate when form is submitted', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Tên khách hàng *'), 'Nguyễn Văn Test');
|
||||
await user.type(screen.getByLabelText('Số điện thoại *'), '0901234567');
|
||||
await user.click(screen.getByText('Tạo lead'));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders description text', () => {
|
||||
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Nhập thông tin khách hàng tiềm năng')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
139
apps/web/components/leads/__tests__/lead-detail-dialog.spec.tsx
Normal file
139
apps/web/components/leads/__tests__/lead-detail-dialog.spec.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { LeadReadDto } from '@/lib/leads-api';
|
||||
import { LeadDetailDialog } from '../lead-detail-dialog';
|
||||
|
||||
// Mock hooks
|
||||
const mockUpdateMutate = vi.fn();
|
||||
const mockDeleteMutate = vi.fn();
|
||||
vi.mock('@/lib/hooks/use-leads', () => ({
|
||||
useUpdateLeadStatus: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteLead: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Dialog components
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const mockLead: LeadReadDto = {
|
||||
id: 'lead-1',
|
||||
agentId: 'agent-1',
|
||||
name: 'Trần Thị B',
|
||||
phone: '0987654321',
|
||||
email: 'tran@example.com',
|
||||
source: 'website',
|
||||
score: 75,
|
||||
notes: { text: 'Quan tâm căn hộ Quận 7' },
|
||||
status: 'NEW',
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
updatedAt: '2026-01-16T14:00:00Z',
|
||||
};
|
||||
|
||||
describe('LeadDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateMutate.mockClear();
|
||||
mockDeleteMutate.mockClear();
|
||||
});
|
||||
|
||||
it('returns null when lead is null', () => {
|
||||
const { container } = render(
|
||||
<LeadDetailDialog lead={null} open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders dialog title', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Chi tiết lead')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders lead name', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
// Name appears in both the description and the contact card
|
||||
expect(screen.getAllByText('Trần Thị B').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders phone number', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText(/0987654321/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email when present', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText(/tran@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders score', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('75/100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notes', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Quan tâm căn hộ Quận 7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quick contact links', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
// Emoji prefixed text
|
||||
const content = document.body.textContent;
|
||||
expect(content).toContain('Gọi điện');
|
||||
expect(content).toContain('Zalo');
|
||||
});
|
||||
|
||||
it('renders Zalo link with correct phone format', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
const links = document.querySelectorAll('a[href*="zalo.me"]');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
expect(links[0]).toHaveAttribute('href', 'https://zalo.me/84987654321');
|
||||
});
|
||||
|
||||
it('renders delete button', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Xóa lead')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirmation on first delete click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText('Xóa lead'));
|
||||
expect(screen.getByText('Xác nhận xóa?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Đóng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status change select', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Chuyển trạng thái')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders timeline section', () => {
|
||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||
expect(screen.getByText('Lịch sử')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides email contact when email is null', () => {
|
||||
const leadNoEmail = { ...mockLead, email: null };
|
||||
render(<LeadDetailDialog lead={leadNoEmail} open={true} onOpenChange={vi.fn()} />);
|
||||
const content = document.body.textContent;
|
||||
expect(content).not.toContain('tran@example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LeadStatusBadge } from '../lead-status-badge';
|
||||
|
||||
describe('LeadStatusBadge', () => {
|
||||
it('renders NEW status with correct label', () => {
|
||||
render(<LeadStatusBadge status="NEW" />);
|
||||
expect(screen.getByText('Mới')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CONTACTED status with correct label', () => {
|
||||
render(<LeadStatusBadge status="CONTACTED" />);
|
||||
expect(screen.getByText('Đã liên hệ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders QUALIFIED status with correct label', () => {
|
||||
render(<LeadStatusBadge status="QUALIFIED" />);
|
||||
expect(screen.getByText('Đủ điều kiện')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NEGOTIATING status with correct label', () => {
|
||||
render(<LeadStatusBadge status="NEGOTIATING" />);
|
||||
expect(screen.getByText('Đang thương lượng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CONVERTED status with correct label', () => {
|
||||
render(<LeadStatusBadge status="CONVERTED" />);
|
||||
expect(screen.getByText('Chuyển đổi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders LOST status with correct label', () => {
|
||||
render(<LeadStatusBadge status="LOST" />);
|
||||
expect(screen.getByText('Mất')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to raw status value for unknown status', () => {
|
||||
// @ts-expect-error testing unknown status
|
||||
render(<LeadStatusBadge status="UNKNOWN" />);
|
||||
expect(screen.getByText('UNKNOWN')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
153
apps/web/components/leads/create-lead-dialog.tsx
Normal file
153
apps/web/components/leads/create-lead-dialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCreateLead } from '@/lib/hooks/use-leads';
|
||||
import { LEAD_SOURCES } from '@/lib/leads-api';
|
||||
|
||||
interface CreateLeadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateLeadDialog({ open, onOpenChange }: CreateLeadDialogProps) {
|
||||
const createLead = useCreateLead();
|
||||
const [form, setForm] = React.useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
source: 'website',
|
||||
score: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createLead.mutate(
|
||||
{
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
email: form.email || undefined,
|
||||
source: form.source,
|
||||
score: form.score ? Number(form.score) : undefined,
|
||||
notes: form.notes ? { text: form.notes } : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', phone: '', email: '', source: 'website', score: '', notes: '' });
|
||||
onOpenChange(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Thêm lead mới</DialogTitle>
|
||||
<DialogDescription>
|
||||
Nhập thông tin khách hàng tiềm năng
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead-name">Tên khách hàng *</Label>
|
||||
<Input
|
||||
id="lead-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Nguyễn Văn A"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead-phone">Số điện thoại *</Label>
|
||||
<Input
|
||||
id="lead-phone"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
placeholder="0901234567"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead-email">Email</Label>
|
||||
<Input
|
||||
id="lead-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead-source">Nguồn</Label>
|
||||
<Select
|
||||
id="lead-source"
|
||||
value={form.source}
|
||||
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
|
||||
>
|
||||
{LEAD_SOURCES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead-score">Điểm (0-100)</Label>
|
||||
<Input
|
||||
id="lead-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.score}
|
||||
onChange={(e) => setForm((f) => ({ ...f, score: e.target.value }))}
|
||||
placeholder="75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead-notes">Ghi chú</Label>
|
||||
<Textarea
|
||||
id="lead-notes"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Thông tin bổ sung về khách hàng..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button type="submit" disabled={createLead.isPending}>
|
||||
{createLead.isPending ? 'Đang tạo...' : 'Tạo lead'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
212
apps/web/components/leads/lead-detail-dialog.tsx
Normal file
212
apps/web/components/leads/lead-detail-dialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { LeadStatusBadge } from '@/components/leads/lead-status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
||||
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
||||
|
||||
interface LeadDetailDialogProps {
|
||||
lead: LeadReadDto | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(LEAD_STATUSES) as [LeadStatus, { label: string }][];
|
||||
|
||||
function getSourceLabel(source: string): string {
|
||||
const found = LEAD_SOURCES.find((s) => s.value === source);
|
||||
return found?.label ?? source;
|
||||
}
|
||||
|
||||
export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogProps) {
|
||||
const updateStatus = useUpdateLeadStatus();
|
||||
const deleteLead = useDeleteLead();
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
|
||||
if (!lead) return null;
|
||||
|
||||
const handleStatusChange = (newStatus: LeadStatus) => {
|
||||
updateStatus.mutate(
|
||||
{ id: lead.id, status: newStatus },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
return;
|
||||
}
|
||||
deleteLead.mutate(lead.id, {
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createdDate = new Date(lead.createdAt).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const updatedDate = new Date(lead.updatedAt).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const notes = lead.notes && typeof lead.notes === 'object' && 'text' in lead.notes
|
||||
? String(lead.notes['text'])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); setConfirmDelete(false); }}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Chi tiết lead</DialogTitle>
|
||||
<DialogDescription>{lead.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Contact info */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{lead.name}</span>
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>SĐT: {lead.phone}</p>
|
||||
{lead.email && <p>Email: {lead.email}</p>}
|
||||
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
||||
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-sm font-medium">Lịch sử</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-muted-foreground">Tạo lúc: {createdDate}</span>
|
||||
</div>
|
||||
{lead.createdAt !== lead.updatedAt && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-muted-foreground">Cập nhật lúc: {updatedDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{notes && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-sm font-medium">Ghi chú</h4>
|
||||
<div className="rounded-lg bg-muted p-3 text-sm leading-relaxed">
|
||||
{notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Score bar */}
|
||||
{lead.score !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-sm font-medium">Điểm lead</h4>
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-primary transition-all"
|
||||
style={{ width: `${lead.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-right">{lead.score}/100</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={`tel:${lead.phone}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
📞 Gọi điện
|
||||
</a>
|
||||
{lead.email && (
|
||||
<a
|
||||
href={`mailto:${lead.email}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
✉️ Email
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
💬 Zalo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status change */}
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-sm font-medium">Chuyển trạng thái</h4>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onChange={(e) => handleStatusChange(e.target.value as LeadStatus)}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
{STATUS_OPTIONS.map(([value, { label }]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteLead.isPending}
|
||||
>
|
||||
{confirmDelete
|
||||
? deleteLead.isPending
|
||||
? 'Đang xóa...'
|
||||
: 'Xác nhận xóa?'
|
||||
: 'Xóa lead'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Đóng
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
11
apps/web/components/leads/lead-status-badge.tsx
Normal file
11
apps/web/components/leads/lead-status-badge.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LEAD_STATUSES, type LeadStatus } from '@/lib/leads-api';
|
||||
|
||||
interface LeadStatusBadgeProps {
|
||||
status: LeadStatus;
|
||||
}
|
||||
|
||||
export function LeadStatusBadge({ status }: LeadStatusBadgeProps) {
|
||||
const config = LEAD_STATUSES[status] ?? { label: status, variant: 'outline' as const };
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
Reference in New Issue
Block a user