Files
goodgo-platform/apps/web/components/leads/lead-detail-dialog.tsx
Ho Ngoc Hai 1fbe2f4e73 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>
2026-04-11 23:43:20 +07:00

213 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}