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>
431 lines
14 KiB
TypeScript
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;
|