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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user