EPIC-P2-001: Frontend Actions Implementation - Replace 31 console.log placeholders with navigate() calls - 16 pages updated with proper routing EPIC-P2-002: Settings Subpages Creation - Add CompanySettingsPage.tsx - Add ProfileSettingsPage.tsx - Add SecuritySettingsPage.tsx - Add SystemSettingsPage.tsx - Update router with new routes EPIC-P2-003: Bug Fix ValuationReportsPage - Fix recursive getToday() function EPIC-P2-006: CRM Pipeline Kanban - Add PipelineKanbanPage.tsx - Add KanbanColumn.tsx component - Add KanbanCard.tsx component - Add CRM routes to router Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Users,
|
|
Plus,
|
|
MoreVertical,
|
|
Eye,
|
|
UserCheck,
|
|
XCircle,
|
|
RefreshCw,
|
|
Search,
|
|
Star,
|
|
Phone,
|
|
Mail,
|
|
TrendingUp,
|
|
} from 'lucide-react';
|
|
import { Button } from '@components/atoms/Button';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
|
import { ConfirmModal } from '@components/organisms/Modal';
|
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
|
import { useLeads } from '@features/crm/hooks';
|
|
import type { Lead, LeadStatus, LeadSource } from '@features/crm/types';
|
|
import { formatNumber } from '@utils/formatters';
|
|
|
|
const statusLabels: Record<LeadStatus, string> = {
|
|
new: 'Nuevo',
|
|
contacted: 'Contactado',
|
|
qualified: 'Calificado',
|
|
converted: 'Convertido',
|
|
lost: 'Perdido',
|
|
};
|
|
|
|
const statusColors: Record<LeadStatus, string> = {
|
|
new: 'bg-blue-100 text-blue-700',
|
|
contacted: 'bg-yellow-100 text-yellow-700',
|
|
qualified: 'bg-green-100 text-green-700',
|
|
converted: 'bg-purple-100 text-purple-700',
|
|
lost: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
const sourceLabels: Record<LeadSource, string> = {
|
|
website: 'Sitio Web',
|
|
phone: 'Telefono',
|
|
email: 'Email',
|
|
referral: 'Referido',
|
|
social_media: 'Redes Sociales',
|
|
advertising: 'Publicidad',
|
|
event: 'Evento',
|
|
other: 'Otro',
|
|
};
|
|
|
|
const formatCurrency = (value: number): string => {
|
|
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
};
|
|
|
|
export function LeadsPage() {
|
|
const navigate = useNavigate();
|
|
const [selectedStatus, setSelectedStatus] = useState<LeadStatus | ''>('');
|
|
const [selectedSource, setSelectedSource] = useState<LeadSource | ''>('');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [leadToConvert, setLeadToConvert] = useState<Lead | null>(null);
|
|
const [leadToLose, setLeadToLose] = useState<Lead | null>(null);
|
|
|
|
const {
|
|
leads,
|
|
total,
|
|
page,
|
|
totalPages,
|
|
isLoading,
|
|
error,
|
|
setPage,
|
|
refresh,
|
|
convertLead,
|
|
markLeadLost,
|
|
} = useLeads({
|
|
status: selectedStatus || undefined,
|
|
source: selectedSource || undefined,
|
|
search: searchTerm || undefined,
|
|
limit: 20,
|
|
});
|
|
|
|
const getActionsMenu = (lead: Lead): DropdownItem[] => {
|
|
const items: DropdownItem[] = [
|
|
{
|
|
key: 'view',
|
|
label: 'Ver detalle',
|
|
icon: <Eye className="h-4 w-4" />,
|
|
onClick: () => navigate(`/crm/leads/${lead.id}`),
|
|
},
|
|
];
|
|
|
|
if (lead.status !== 'converted' && lead.status !== 'lost') {
|
|
items.push({
|
|
key: 'convert',
|
|
label: 'Convertir a oportunidad',
|
|
icon: <UserCheck className="h-4 w-4" />,
|
|
onClick: () => setLeadToConvert(lead),
|
|
});
|
|
items.push({
|
|
key: 'lose',
|
|
label: 'Marcar como perdido',
|
|
icon: <XCircle className="h-4 w-4" />,
|
|
danger: true,
|
|
onClick: () => setLeadToLose(lead),
|
|
});
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
const columns: Column<Lead>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Lead',
|
|
render: (lead) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
|
|
<Users className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900">{lead.name}</div>
|
|
{lead.contactName && (
|
|
<div className="text-sm text-gray-500">{lead.contactName}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'contact',
|
|
header: 'Contacto',
|
|
render: (lead) => (
|
|
<div className="space-y-1">
|
|
{lead.email && (
|
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
|
<Mail className="h-3 w-3" />
|
|
{lead.email}
|
|
</div>
|
|
)}
|
|
{lead.phone && (
|
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
|
<Phone className="h-3 w-3" />
|
|
{lead.phone}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'source',
|
|
header: 'Origen',
|
|
render: (lead) => (
|
|
<span className="text-sm text-gray-600">
|
|
{lead.source ? sourceLabels[lead.source] : '-'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'probability',
|
|
header: 'Probabilidad',
|
|
render: (lead) => (
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-2 w-16 rounded-full bg-gray-200">
|
|
<div
|
|
className="h-2 rounded-full bg-blue-500"
|
|
style={{ width: `${lead.probability}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-sm text-gray-600">{lead.probability}%</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'expectedRevenue',
|
|
header: 'Ingreso Esperado',
|
|
render: (lead) => (
|
|
<div className="text-right">
|
|
<span className="font-medium text-gray-900">
|
|
{lead.expectedRevenue ? `$${formatCurrency(lead.expectedRevenue)}` : '-'}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'priority',
|
|
header: 'Prioridad',
|
|
render: (lead) => (
|
|
<div className="flex items-center gap-1">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
className={`h-4 w-4 ${i < lead.priority ? 'fill-amber-400 text-amber-400' : 'text-gray-300'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Estado',
|
|
render: (lead) => (
|
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[lead.status]}`}>
|
|
{statusLabels[lead.status]}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (lead) => (
|
|
<Dropdown
|
|
trigger={
|
|
<button className="rounded p-1 hover:bg-gray-100">
|
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
}
|
|
items={getActionsMenu(lead)}
|
|
align="right"
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
const handleConvert = async () => {
|
|
if (leadToConvert) {
|
|
await convertLead(leadToConvert.id, { createOpportunity: true });
|
|
setLeadToConvert(null);
|
|
}
|
|
};
|
|
|
|
const handleLose = async () => {
|
|
if (leadToLose) {
|
|
await markLeadLost(leadToLose.id, 'Perdido por el usuario');
|
|
setLeadToLose(null);
|
|
}
|
|
};
|
|
|
|
// Calculate summary stats
|
|
const newCount = leads.filter(l => l.status === 'new').length;
|
|
const qualifiedCount = leads.filter(l => l.status === 'qualified').length;
|
|
const convertedCount = leads.filter(l => l.status === 'converted').length;
|
|
const totalExpectedRevenue = leads.reduce((sum, l) => sum + (l.expectedRevenue || 0), 0);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6">
|
|
<ErrorEmptyState onRetry={refresh} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
<Breadcrumbs items={[
|
|
{ label: 'CRM', href: '/crm' },
|
|
{ label: 'Leads' },
|
|
]} />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Leads</h1>
|
|
<p className="text-sm text-gray-500">
|
|
Gestiona prospectos y convierte leads en oportunidades
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Actualizar
|
|
</Button>
|
|
<Button>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Nuevo lead
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('new')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
|
<Users className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Nuevos</div>
|
|
<div className="text-xl font-bold text-blue-600">{newCount}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('qualified')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
|
<Star className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Calificados</div>
|
|
<div className="text-xl font-bold text-green-600">{qualifiedCount}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('converted')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
|
<UserCheck className="h-5 w-5 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Convertidos</div>
|
|
<div className="text-xl font-bold text-purple-600">{convertedCount}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
|
<TrendingUp className="h-5 w-5 text-amber-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Potencial Total</div>
|
|
<div className="text-xl font-bold text-amber-600">${formatCurrency(totalExpectedRevenue)}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Lista de Leads</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar leads..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={selectedStatus}
|
|
onChange={(e) => setSelectedStatus(e.target.value as LeadStatus | '')}
|
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="">Todos los estados</option>
|
|
{Object.entries(statusLabels).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={selectedSource}
|
|
onChange={(e) => setSelectedSource(e.target.value as LeadSource | '')}
|
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="">Todos los origenes</option>
|
|
{Object.entries(sourceLabels).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
|
|
{(selectedStatus || selectedSource || searchTerm) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedStatus('');
|
|
setSelectedSource('');
|
|
setSearchTerm('');
|
|
}}
|
|
>
|
|
Limpiar filtros
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{leads.length === 0 && !isLoading ? (
|
|
<NoDataEmptyState
|
|
entityName="leads"
|
|
/>
|
|
) : (
|
|
<DataTable
|
|
data={leads}
|
|
columns={columns}
|
|
isLoading={isLoading}
|
|
pagination={{
|
|
page,
|
|
totalPages,
|
|
total,
|
|
limit: 20,
|
|
onPageChange: setPage,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Convert Lead Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!leadToConvert}
|
|
onClose={() => setLeadToConvert(null)}
|
|
onConfirm={handleConvert}
|
|
title="Convertir lead"
|
|
message={`¿Convertir "${leadToConvert?.name}" en una oportunidad de venta?`}
|
|
variant="success"
|
|
confirmText="Convertir"
|
|
/>
|
|
|
|
{/* Lose Lead Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!leadToLose}
|
|
onClose={() => setLeadToLose(null)}
|
|
onConfirm={handleLose}
|
|
title="Marcar como perdido"
|
|
message={`¿Marcar el lead "${leadToLose?.name}" como perdido? Esta accion no se puede deshacer.`}
|
|
variant="danger"
|
|
confirmText="Marcar como perdido"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LeadsPage;
|