erp-core-frontend-web/src/pages/crm/PipelineKanbanPage.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

431 lines
14 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Plus,
RefreshCw,
Search,
Filter,
List,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { DateRangePicker, type DateRange } from '@components/organisms/DatePicker';
import { Select, type SelectOption } from '@components/organisms/Select';
import { useToast } from '@components/organisms/Toast';
import { KanbanColumn, type StageKey } from '@components/crm/KanbanColumn';
import type { KanbanOpportunity } from '@components/crm/KanbanCard';
import { formatNumber } from '@utils/formatters';
import { Link } from 'react-router-dom';
// Stage labels for display
const stageLabels: Record<StageKey, string> = {
new: 'Nuevo',
qualified: 'Calificado',
proposition: 'Propuesta',
won: 'Ganado',
lost: 'Perdido',
};
// Mock users for filter
const mockUsers: SelectOption[] = [
{ value: 'user-1', label: 'Juan Garcia' },
{ value: 'user-2', label: 'Maria Lopez' },
{ value: 'user-3', label: 'Carlos Rodriguez' },
{ value: 'user-4', label: 'Ana Martinez' },
];
// Mock data for opportunities
const generateMockOpportunities = (): Record<StageKey, KanbanOpportunity[]> => ({
new: [
{
id: '1',
name: 'Proyecto ERP para Constructora ABC',
expectedRevenue: 150000,
expectedCloseDate: '2026-02-15',
assignedTo: { id: 'user-1', name: 'Juan Garcia' },
probability: 20,
partnerName: 'Constructora ABC S.A.',
},
{
id: '2',
name: 'Sistema de Inventarios - Retail Plus',
expectedRevenue: 75000,
expectedCloseDate: '2026-02-28',
assignedTo: { id: 'user-2', name: 'Maria Lopez' },
probability: 15,
partnerName: 'Retail Plus',
},
{
id: '3',
name: 'Integracion CRM - TechCorp',
expectedRevenue: 45000,
probability: 10,
partnerName: 'TechCorp Internacional',
},
],
qualified: [
{
id: '4',
name: 'Modulo de Facturacion Electronica',
expectedRevenue: 85000,
expectedCloseDate: '2026-02-10',
assignedTo: { id: 'user-1', name: 'Juan Garcia' },
probability: 40,
partnerName: 'Distribuidora Nacional',
},
{
id: '5',
name: 'Dashboard Ejecutivo - Financiera',
expectedRevenue: 120000,
expectedCloseDate: '2026-03-01',
assignedTo: { id: 'user-3', name: 'Carlos Rodriguez' },
probability: 50,
partnerName: 'Financiera del Norte',
},
],
proposition: [
{
id: '6',
name: 'Sistema de Gestion de Proyectos',
expectedRevenue: 200000,
expectedCloseDate: '2026-01-30',
assignedTo: { id: 'user-2', name: 'Maria Lopez' },
probability: 65,
partnerName: 'Consultoria Global',
},
{
id: '7',
name: 'Automatizacion de Procesos - Manufactura',
expectedRevenue: 180000,
expectedCloseDate: '2026-02-05',
assignedTo: { id: 'user-4', name: 'Ana Martinez' },
probability: 70,
partnerName: 'Manufactura Industrial',
},
{
id: '8',
name: 'Portal de Clientes Web',
expectedRevenue: 95000,
expectedCloseDate: '2026-02-20',
assignedTo: { id: 'user-1', name: 'Juan Garcia' },
probability: 60,
partnerName: 'Servicios Express',
},
],
won: [
{
id: '9',
name: 'Implementacion ERP Completo',
expectedRevenue: 350000,
expectedCloseDate: '2026-01-15',
assignedTo: { id: 'user-3', name: 'Carlos Rodriguez' },
probability: 100,
partnerName: 'Grupo Industrial MX',
},
{
id: '10',
name: 'Modulo de Recursos Humanos',
expectedRevenue: 65000,
expectedCloseDate: '2026-01-10',
assignedTo: { id: 'user-2', name: 'Maria Lopez' },
probability: 100,
partnerName: 'Corporativo Sur',
},
],
lost: [
{
id: '11',
name: 'Migracion de Sistema Legacy',
expectedRevenue: 125000,
expectedCloseDate: '2026-01-05',
assignedTo: { id: 'user-4', name: 'Ana Martinez' },
probability: 0,
partnerName: 'Empresa Tradicional',
},
],
});
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
};
export function PipelineKanbanPage() {
const { success } = useToast();
// State for filters
const [searchTerm, setSearchTerm] = useState('');
const [dateRange, setDateRange] = useState<DateRange>({ start: null, end: null });
const [selectedUser, setSelectedUser] = useState<string>('');
const [showFilters, setShowFilters] = useState(false);
// State for opportunities by stage
const [opportunitiesByStage, setOpportunitiesByStage] = useState<Record<StageKey, KanbanOpportunity[]>>(
generateMockOpportunities
);
// Filter opportunities based on search and filters
const filteredOpportunities = useMemo(() => {
const result: Record<StageKey, KanbanOpportunity[]> = {
new: [],
qualified: [],
proposition: [],
won: [],
lost: [],
};
(Object.keys(opportunitiesByStage) as StageKey[]).forEach((stage) => {
result[stage] = opportunitiesByStage[stage].filter((opp) => {
// Search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
if (
!opp.name.toLowerCase().includes(searchLower) &&
!opp.partnerName?.toLowerCase().includes(searchLower)
) {
return false;
}
}
// User filter
if (selectedUser && opp.assignedTo?.id !== selectedUser) {
return false;
}
// Date range filter
if (dateRange.start && dateRange.end && opp.expectedCloseDate) {
const closeDate = new Date(opp.expectedCloseDate);
if (closeDate < dateRange.start || closeDate > dateRange.end) {
return false;
}
}
return true;
});
});
return result;
}, [opportunitiesByStage, searchTerm, selectedUser, dateRange]);
// Calculate totals
const totals = useMemo(() => {
const allOpps = Object.values(filteredOpportunities).flat();
const openOpps = [...filteredOpportunities.new, ...filteredOpportunities.qualified, ...filteredOpportunities.proposition];
return {
totalOpportunities: allOpps.length,
totalValue: allOpps.reduce((sum, o) => sum + o.expectedRevenue, 0),
openValue: openOpps.reduce((sum, o) => sum + o.expectedRevenue, 0),
weightedValue: openOpps.reduce((sum, o) => sum + (o.expectedRevenue * o.probability / 100), 0),
wonValue: filteredOpportunities.won.reduce((sum, o) => sum + o.expectedRevenue, 0),
};
}, [filteredOpportunities]);
// Handle card drop (stage change)
const handleDrop = (opportunity: KanbanOpportunity, targetStage: StageKey) => {
// Find current stage
let currentStage: StageKey | null = null;
for (const [stage, items] of Object.entries(opportunitiesByStage)) {
if (items.some((item) => item.id === opportunity.id)) {
currentStage = stage as StageKey;
break;
}
}
if (!currentStage || currentStage === targetStage) return;
// Update state
setOpportunitiesByStage((prev) => {
const newState = { ...prev };
// Remove from current stage
newState[currentStage!] = prev[currentStage!].filter((item) => item.id !== opportunity.id);
// Update probability based on stage
const updatedOpp = { ...opportunity };
if (targetStage === 'won') {
updatedOpp.probability = 100;
} else if (targetStage === 'lost') {
updatedOpp.probability = 0;
}
// Add to target stage
newState[targetStage] = [...prev[targetStage], updatedOpp];
return newState;
});
// Show toast notification
success(
'Etapa actualizada',
`"${opportunity.name}" movida a ${stageLabels[targetStage]}`
);
};
// Handle card click
const handleCardClick = (opportunity: KanbanOpportunity) => {
console.log('Card clicked:', opportunity);
// Here you could navigate to opportunity detail or open a modal
};
// Refresh data
const handleRefresh = () => {
setOpportunitiesByStage(generateMockOpportunities());
success('Datos actualizados', 'El pipeline ha sido actualizado');
};
// Clear filters
const handleClearFilters = () => {
setSearchTerm('');
setDateRange({ start: null, end: null });
setSelectedUser('');
};
const hasActiveFilters = searchTerm || selectedUser || dateRange.start;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 space-y-4 p-6 pb-0">
<Breadcrumbs
items={[
{ label: 'CRM', href: '/crm' },
{ label: 'Pipeline' },
]}
/>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pipeline de Ventas</h1>
<p className="text-sm text-gray-500">
Gestiona oportunidades arrastrando tarjetas entre etapas
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="mr-2 h-4 w-4" />
Actualizar
</Button>
<Link to="/crm/opportunities">
<Button variant="outline">
<List className="mr-2 h-4 w-4" />
Vista Lista
</Button>
</Link>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva oportunidad
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Oportunidades</div>
<div className="text-xl font-bold text-gray-900">{totals.totalOpportunities}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Pipeline Total</div>
<div className="text-xl font-bold text-blue-600">${formatCurrency(totals.openValue)}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Ponderado</div>
<div className="text-xl font-bold text-amber-600">${formatCurrency(totals.weightedValue)}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Ganado</div>
<div className="text-xl font-bold text-green-600">${formatCurrency(totals.wonValue)}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Valor Total</div>
<div className="text-xl font-bold text-gray-900">${formatCurrency(totals.totalValue)}</div>
</div>
</div>
{/* Filters */}
<div className="rounded-lg border bg-white p-4">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar oportunidades..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* Toggle Filters */}
<Button
variant={showFilters ? 'secondary' : 'outline'}
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="mr-2 h-4 w-4" />
Filtros
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary-600 px-1.5 py-0.5 text-xs text-white">
{[searchTerm, selectedUser, dateRange.start].filter(Boolean).length}
</span>
)}
</Button>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
Limpiar filtros
</Button>
)}
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="mt-4 flex flex-wrap items-end gap-4 border-t pt-4">
<div className="min-w-[200px]">
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Responsable
</label>
<Select
options={mockUsers}
value={selectedUser}
onChange={(v) => setSelectedUser(v as string)}
placeholder="Todos los usuarios"
clearable
/>
</div>
<div className="min-w-[280px]">
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Fecha de cierre esperada
</label>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
placeholder="Seleccionar rango"
/>
</div>
</div>
)}
</div>
</div>
{/* Kanban Board */}
<div className="flex-1 overflow-x-auto p-6">
<div className="flex gap-4 min-w-max">
{(Object.keys(stageLabels) as StageKey[]).map((stage) => (
<KanbanColumn
key={stage}
title={stageLabels[stage]}
stage={stage}
items={filteredOpportunities[stage]}
onDrop={handleDrop}
onCardClick={handleCardClick}
/>
))}
</div>
</div>
</div>
);
}
export default PipelineKanbanPage;