erp-core-frontend-v2/src/pages/crm/LeadsPage.tsx
rckrdmrd 3a461cb184 [TASK-2026-01-20-005] feat: Resolve P2 gaps - Actions, Settings, Kanban
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>
2026-01-20 04:32:20 -06:00

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;