feat: Implement CRM and External Integrations
This commit is contained in:
38
src/App.tsx
38
src/App.tsx
@@ -60,6 +60,11 @@ import ContentLibrary from "./pages/dashboard/guides/ContentLibrary";
|
|||||||
import CommissionsDashboard from "./pages/dashboard/commissions/CommissionsDashboard";
|
import CommissionsDashboard from "./pages/dashboard/commissions/CommissionsDashboard";
|
||||||
import CommissionRules from "./pages/dashboard/commissions/CommissionRules";
|
import CommissionRules from "./pages/dashboard/commissions/CommissionRules";
|
||||||
import PaymentHistory from "./pages/dashboard/commissions/PaymentHistory";
|
import PaymentHistory from "./pages/dashboard/commissions/PaymentHistory";
|
||||||
|
// CRM pages
|
||||||
|
import CRMDashboard from "./pages/dashboard/crm/CRMDashboard";
|
||||||
|
import CRMContacts from "./pages/dashboard/crm/Contacts";
|
||||||
|
import CRMCampaigns from "./pages/dashboard/crm/Campaigns";
|
||||||
|
import CRMAnalytics from "./pages/dashboard/crm/Analytics";
|
||||||
// Tourist App
|
// Tourist App
|
||||||
import TouristApp from "./pages/TouristApp";
|
import TouristApp from "./pages/TouristApp";
|
||||||
// Commerce pages (for retail stores)
|
// Commerce pages (for retail stores)
|
||||||
@@ -655,6 +660,39 @@ const AppRouter = () => (
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* CRM Routes */}
|
||||||
|
<Route path="/dashboard/crm/dashboard" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<CRMDashboard />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/dashboard/crm/contacts" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<CRMContacts />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/dashboard/crm/campaigns" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<CRMCampaigns />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/dashboard/crm/analytics" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<CRMAnalytics />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Catch-all route */}
|
{/* Catch-all route */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Server,
|
Server,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
UserCircle
|
UserCircle,
|
||||||
|
Mail
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -128,6 +129,17 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
{ icon: FileText, label: 'Historial', path: '/dashboard/commissions/payments' }
|
{ icon: FileText, label: 'Historial', path: '/dashboard/commissions/payments' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: 'CRM',
|
||||||
|
path: '/dashboard/crm',
|
||||||
|
subItems: [
|
||||||
|
{ icon: BarChart3, label: 'Dashboard', path: '/dashboard/crm/dashboard' },
|
||||||
|
{ icon: Users, label: 'Contactos', path: '/dashboard/crm/contacts' },
|
||||||
|
{ icon: Mail, label: 'Campañas', path: '/dashboard/crm/campaigns' },
|
||||||
|
{ icon: BarChart3, label: 'Analytics', path: '/dashboard/crm/analytics' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: 'Configuración',
|
label: 'Configuración',
|
||||||
|
|||||||
286
src/pages/dashboard/crm/Analytics.tsx
Normal file
286
src/pages/dashboard/crm/Analytics.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
Activity,
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
const Analytics = () => {
|
||||||
|
const [timeRange, setTimeRange] = useState('30d');
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
title: 'Nuevos Clientes',
|
||||||
|
value: '342',
|
||||||
|
change: '+18.2%',
|
||||||
|
trend: 'up',
|
||||||
|
icon: Users,
|
||||||
|
color: 'text-blue-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tasa de Retención',
|
||||||
|
value: '84.2%',
|
||||||
|
change: '-2.1%',
|
||||||
|
trend: 'down',
|
||||||
|
icon: Activity,
|
||||||
|
color: 'text-green-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ingresos por Cliente',
|
||||||
|
value: '$1,234',
|
||||||
|
change: '+12.5%',
|
||||||
|
trend: 'up',
|
||||||
|
icon: DollarSign,
|
||||||
|
color: 'text-orange-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Engagement Score',
|
||||||
|
value: '7.8/10',
|
||||||
|
change: '+0.5',
|
||||||
|
trend: 'up',
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: 'text-purple-500'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const segmentPerformance = [
|
||||||
|
{
|
||||||
|
segment: 'VIP Travelers',
|
||||||
|
customers: 342,
|
||||||
|
revenue: '$145,230',
|
||||||
|
avgSpent: '$425',
|
||||||
|
retention: '92%',
|
||||||
|
growth: '+18%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: 'Business',
|
||||||
|
customers: 456,
|
||||||
|
revenue: '$187,900',
|
||||||
|
avgSpent: '$412',
|
||||||
|
retention: '88%',
|
||||||
|
growth: '+25%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: 'Familias',
|
||||||
|
customers: 891,
|
||||||
|
revenue: '$98,450',
|
||||||
|
avgSpent: '$110',
|
||||||
|
retention: '76%',
|
||||||
|
growth: '+12%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: 'Aventureros',
|
||||||
|
customers: 623,
|
||||||
|
revenue: '$76,340',
|
||||||
|
avgSpent: '$123',
|
||||||
|
retention: '81%',
|
||||||
|
growth: '+8%'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const customerJourney = [
|
||||||
|
{ stage: 'Descubrimiento', count: 5420, conversion: '100%', color: 'bg-blue-500' },
|
||||||
|
{ stage: 'Consideración', count: 3845, conversion: '71%', color: 'bg-green-500' },
|
||||||
|
{ stage: 'Decisión', count: 2156, conversion: '56%', color: 'bg-orange-500' },
|
||||||
|
{ stage: 'Compra', count: 1284, conversion: '60%', color: 'bg-purple-500' },
|
||||||
|
{ stage: 'Fidelización', count: 956, conversion: '74%', color: 'bg-pink-500' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const topActions = [
|
||||||
|
{ action: 'Abrir emails', count: 4523, percentage: 85 },
|
||||||
|
{ action: 'Visitar sitio web', count: 3891, percentage: 73 },
|
||||||
|
{ action: 'Ver productos', count: 2456, percentage: 61 },
|
||||||
|
{ action: 'Agregar al carrito', count: 1234, percentage: 50 },
|
||||||
|
{ action: 'Completar compra', count: 789, percentage: 64 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Analytics CRM</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Análisis detallado del comportamiento de clientes</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7d">Últimos 7 días</SelectItem>
|
||||||
|
<SelectItem value="30d">Últimos 30 días</SelectItem>
|
||||||
|
<SelectItem value="90d">Últimos 90 días</SelectItem>
|
||||||
|
<SelectItem value="1y">Último año</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Exportar Reporte
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const Icon = metric.icon;
|
||||||
|
return (
|
||||||
|
<Card key={metric.title}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
{metric.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Icon className={`h-5 w-5 ${metric.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{metric.value}</div>
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
{metric.trend === 'up' ? (
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="h-4 w-4 text-red-500 mr-1" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm ${metric.trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{metric.change}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500 ml-2">vs período anterior</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment Performance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rendimiento por Segmento</CardTitle>
|
||||||
|
<CardDescription>Métricas clave de cada grupo de clientes</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{segmentPerformance.map((segment, index) => (
|
||||||
|
<div key={index} className="border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center text-white font-bold">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{segment.segment}</h3>
|
||||||
|
<p className="text-sm text-gray-600">{segment.customers} clientes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={segment.growth.startsWith('+') ? 'default' : 'secondary'}>
|
||||||
|
{segment.growth} crecimiento
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Ingresos Totales</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{segment.revenue}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Gasto Promedio</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{segment.avgSpent}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Retención</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{segment.retention}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Clientes</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{segment.customers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Customer Journey */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Embudo de Conversión</CardTitle>
|
||||||
|
<CardDescription>Journey del cliente desde descubrimiento hasta fidelización</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{customerJourney.map((stage, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-3 h-3 rounded-full ${stage.color}`}></span>
|
||||||
|
<span className="font-medium text-gray-900">{stage.stage}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600">{stage.count.toLocaleString()}</span>
|
||||||
|
<Badge variant="outline">{stage.conversion}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${stage.color}`}
|
||||||
|
style={{ width: stage.conversion }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Acciones Principales</CardTitle>
|
||||||
|
<CardDescription>Actividades más comunes de los clientes</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{topActions.map((action, index) => (
|
||||||
|
<div key={index} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-900">{action.action}</span>
|
||||||
|
<span className="text-sm text-gray-600">{action.count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="absolute h-2 rounded-full bg-gradient-to-r from-orange-500 to-red-500"
|
||||||
|
style={{ width: `${action.percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="text-xs text-gray-500">{action.percentage}% de conversión</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Analytics;
|
||||||
327
src/pages/dashboard/crm/CRMDashboard.tsx
Normal file
327
src/pages/dashboard/crm/CRMDashboard.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Target,
|
||||||
|
DollarSign,
|
||||||
|
Calendar,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
MoreVertical
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
const CRMDashboard = () => {
|
||||||
|
const [timeRange, setTimeRange] = useState('30d');
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
title: 'Total Clientes',
|
||||||
|
value: '2,847',
|
||||||
|
change: '+12.5%',
|
||||||
|
trend: 'up',
|
||||||
|
icon: Users,
|
||||||
|
color: 'text-blue-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clientes Activos',
|
||||||
|
value: '1,923',
|
||||||
|
change: '+8.2%',
|
||||||
|
trend: 'up',
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: 'text-green-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Valor de Vida (LTV)',
|
||||||
|
value: '$4,325',
|
||||||
|
change: '+15.3%',
|
||||||
|
trend: 'up',
|
||||||
|
icon: DollarSign,
|
||||||
|
color: 'text-orange-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tasa de Retención',
|
||||||
|
value: '84.2%',
|
||||||
|
change: '-2.1%',
|
||||||
|
trend: 'down',
|
||||||
|
icon: Target,
|
||||||
|
color: 'text-purple-500'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentActivities = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
customer: 'María González',
|
||||||
|
action: 'Completó reserva',
|
||||||
|
value: '$450',
|
||||||
|
time: 'Hace 5 min',
|
||||||
|
type: 'booking'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
customer: 'Juan Pérez',
|
||||||
|
action: 'Solicitó información',
|
||||||
|
value: null,
|
||||||
|
time: 'Hace 12 min',
|
||||||
|
type: 'inquiry'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
customer: 'Ana Martínez',
|
||||||
|
action: 'Renovó suscripción',
|
||||||
|
value: '$89/mes',
|
||||||
|
time: 'Hace 25 min',
|
||||||
|
type: 'subscription'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
customer: 'Carlos López',
|
||||||
|
action: 'Dejó review',
|
||||||
|
value: '5 estrellas',
|
||||||
|
time: 'Hace 1 hora',
|
||||||
|
type: 'review'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const topSegments = [
|
||||||
|
{ name: 'VIP Travelers', count: 342, revenue: '$145,230', growth: '+18%' },
|
||||||
|
{ name: 'Familias', count: 891, revenue: '$98,450', growth: '+12%' },
|
||||||
|
{ name: 'Business', count: 456, revenue: '$187,900', growth: '+25%' },
|
||||||
|
{ name: 'Aventureros', count: 623, revenue: '$76,340', growth: '+8%' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeCampaigns = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Promoción Verano 2024',
|
||||||
|
status: 'active',
|
||||||
|
sent: 2847,
|
||||||
|
opened: 1423,
|
||||||
|
clicked: 456,
|
||||||
|
conversions: 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Newsletter Mensual',
|
||||||
|
status: 'scheduled',
|
||||||
|
sent: 0,
|
||||||
|
opened: 0,
|
||||||
|
clicked: 0,
|
||||||
|
conversions: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Reactivación Clientes',
|
||||||
|
status: 'active',
|
||||||
|
sent: 1245,
|
||||||
|
opened: 623,
|
||||||
|
clicked: 187,
|
||||||
|
conversions: 34
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">CRM Dashboard</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Gestión de relaciones con clientes</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
{timeRange === '7d' ? 'Últimos 7 días' : timeRange === '30d' ? 'Últimos 30 días' : 'Último año'}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => setTimeRange('7d')}>Últimos 7 días</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTimeRange('30d')}>Últimos 30 días</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTimeRange('1y')}>Último año</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button>
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
Nueva Campaña
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const Icon = metric.icon;
|
||||||
|
return (
|
||||||
|
<Card key={metric.title}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
{metric.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Icon className={`h-5 w-5 ${metric.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{metric.value}</div>
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
{metric.trend === 'up' ? (
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="h-4 w-4 text-red-500 mr-1" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm ${metric.trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{metric.change}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500 ml-2">vs período anterior</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Recent Activities */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Actividad Reciente</CardTitle>
|
||||||
|
<CardDescription>Últimas interacciones con clientes</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivities.map((activity) => (
|
||||||
|
<div key={activity.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center text-white font-semibold">
|
||||||
|
{activity.customer.split(' ').map(n => n[0]).join('')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{activity.customer}</p>
|
||||||
|
<p className="text-sm text-gray-600">{activity.action}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{activity.value && (
|
||||||
|
<p className="font-semibold text-gray-900">{activity.value}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">{activity.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Segments */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Segmentos Principales</CardTitle>
|
||||||
|
<CardDescription>Grupos de clientes de mayor valor</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{topSegments.map((segment, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-gray-900">{segment.name}</p>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{segment.count} clientes
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2">
|
||||||
|
<p className="text-sm text-gray-600">Ingresos: {segment.revenue}</p>
|
||||||
|
<span className="text-xs text-green-600 font-medium">{segment.growth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Campaigns */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Campañas Activas</CardTitle>
|
||||||
|
<CardDescription>Estado de campañas de marketing</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activeCampaigns.map((campaign) => (
|
||||||
|
<div key={campaign.id} className="border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">{campaign.name}</h3>
|
||||||
|
<Badge variant={campaign.status === 'active' ? 'default' : 'secondary'}>
|
||||||
|
{campaign.status === 'active' ? 'Activa' : 'Programada'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Ver Detalles
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Enviados</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{campaign.sent.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Abiertos</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.opened.toLocaleString()}
|
||||||
|
{campaign.sent > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 ml-1">
|
||||||
|
({((campaign.opened / campaign.sent) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Clicks</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.clicked.toLocaleString()}
|
||||||
|
{campaign.opened > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 ml-1">
|
||||||
|
({((campaign.clicked / campaign.opened) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Conversiones</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.conversions.toLocaleString()}
|
||||||
|
{campaign.sent > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 ml-1">
|
||||||
|
({((campaign.conversions / campaign.sent) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CRMDashboard;
|
||||||
419
src/pages/dashboard/crm/Campaigns.tsx
Normal file
419
src/pages/dashboard/crm/Campaigns.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
Eye,
|
||||||
|
MousePointer,
|
||||||
|
ShoppingCart,
|
||||||
|
Calendar
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const campaignSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
|
||||||
|
subject: z.string().trim().min(1, 'Asunto requerido').max(200, 'Asunto muy largo'),
|
||||||
|
segment: z.string().min(1, 'Selecciona un segmento'),
|
||||||
|
message: z.string().trim().min(10, 'Mensaje muy corto').max(2000, 'Mensaje muy largo'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const Campaigns = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
subject: '',
|
||||||
|
segment: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const campaigns = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Promoción Verano 2024',
|
||||||
|
status: 'sent',
|
||||||
|
segment: 'VIP Travelers',
|
||||||
|
sentDate: '2024-03-10',
|
||||||
|
recipients: 2847,
|
||||||
|
opened: 1423,
|
||||||
|
clicked: 456,
|
||||||
|
conversions: 89,
|
||||||
|
revenue: '$12,450'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Newsletter Mensual',
|
||||||
|
status: 'scheduled',
|
||||||
|
segment: 'Todos',
|
||||||
|
sentDate: '2024-03-20',
|
||||||
|
recipients: 5234,
|
||||||
|
opened: 0,
|
||||||
|
clicked: 0,
|
||||||
|
conversions: 0,
|
||||||
|
revenue: '$0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Reactivación Clientes',
|
||||||
|
status: 'sending',
|
||||||
|
segment: 'Inactivos',
|
||||||
|
sentDate: '2024-03-15',
|
||||||
|
recipients: 1245,
|
||||||
|
opened: 623,
|
||||||
|
clicked: 187,
|
||||||
|
conversions: 34,
|
||||||
|
revenue: '$4,230'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Ofertas Especiales Familias',
|
||||||
|
status: 'draft',
|
||||||
|
segment: 'Familias',
|
||||||
|
sentDate: null,
|
||||||
|
recipients: 891,
|
||||||
|
opened: 0,
|
||||||
|
clicked: 0,
|
||||||
|
conversions: 0,
|
||||||
|
revenue: '$0'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const segments = ['Todos', 'VIP Travelers', 'Business', 'Familias', 'Aventureros', 'Inactivos'];
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
campaignSchema.parse(formData);
|
||||||
|
setFormErrors({});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Campaña creada',
|
||||||
|
description: `La campaña "${formData.name}" ha sido creada exitosamente.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
setFormData({ name: '', subject: '', segment: '', message: '' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
errors[err.path[0].toString()] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormErrors(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const statusConfig = {
|
||||||
|
sent: { label: 'Enviada', variant: 'default' as const, icon: CheckCircle, color: 'text-green-600' },
|
||||||
|
scheduled: { label: 'Programada', variant: 'secondary' as const, icon: Clock, color: 'text-blue-600' },
|
||||||
|
sending: { label: 'Enviando', variant: 'outline' as const, icon: Send, color: 'text-orange-600' },
|
||||||
|
draft: { label: 'Borrador', variant: 'outline' as const, icon: Mail, color: 'text-gray-600' },
|
||||||
|
failed: { label: 'Fallida', variant: 'destructive' as const, icon: XCircle, color: 'text-red-600' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusConfig[status as keyof typeof statusConfig] || statusConfig.draft;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Campañas</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Gestiona tus campañas de marketing</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nueva Campaña
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Crear Nueva Campaña</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configura tu campaña de email marketing
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Nombre de la Campaña *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Ej: Promoción Verano 2024"
|
||||||
|
className={formErrors.name ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.name && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject">Asunto del Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||||
|
placeholder="Asunto atractivo para tus clientes"
|
||||||
|
className={formErrors.subject ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.subject && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.subject}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="segment">Segmento Objetivo *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.segment}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, segment: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={formErrors.segment ? 'border-red-500' : ''}>
|
||||||
|
<SelectValue placeholder="Selecciona el público objetivo" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<SelectItem key={segment} value={segment}>
|
||||||
|
{segment}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formErrors.segment && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.segment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="message">Mensaje *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
|
placeholder="Escribe el contenido de tu email..."
|
||||||
|
rows={6}
|
||||||
|
className={formErrors.message ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.message && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{formData.message.length}/2000 caracteres
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Guardar Borrador
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
Crear Campaña
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Total Enviados
|
||||||
|
</CardTitle>
|
||||||
|
<Mail className="h-5 w-5 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">9,326</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Este mes</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Tasa de Apertura
|
||||||
|
</CardTitle>
|
||||||
|
<Eye className="h-5 w-5 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">47.3%</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Promedio</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Click Rate
|
||||||
|
</CardTitle>
|
||||||
|
<MousePointer className="h-5 w-5 text-orange-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">18.6%</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Promedio</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">
|
||||||
|
Ingresos Generados
|
||||||
|
</CardTitle>
|
||||||
|
<ShoppingCart className="h-5 w-5 text-purple-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">$16,680</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Este mes</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaigns List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Campañas</CardTitle>
|
||||||
|
<CardDescription>Todas tus campañas de email marketing</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{campaigns.map((campaign) => {
|
||||||
|
const statusConfig = getStatusBadge(campaign.status);
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={campaign.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center`}>
|
||||||
|
<Mail className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900">{campaign.name}</h3>
|
||||||
|
<Badge variant={statusConfig.variant} className="flex items-center gap-1">
|
||||||
|
<StatusIcon className={`h-3 w-3 ${statusConfig.color}`} />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-1 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{campaign.segment}</span>
|
||||||
|
</div>
|
||||||
|
{campaign.sentDate && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>{campaign.sentDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Ver Resultados
|
||||||
|
</Button>
|
||||||
|
{campaign.status === 'draft' && (
|
||||||
|
<Button size="sm">
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{campaign.status !== 'draft' && (
|
||||||
|
<div className="grid grid-cols-5 gap-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Destinatarios</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.recipients.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Abiertos</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.opened.toLocaleString()}
|
||||||
|
{campaign.recipients > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 ml-1">
|
||||||
|
({((campaign.opened / campaign.recipients) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Clicks</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.clicked.toLocaleString()}
|
||||||
|
{campaign.opened > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 ml-1">
|
||||||
|
({((campaign.clicked / campaign.opened) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Conversiones</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
{campaign.conversions.toLocaleString()}
|
||||||
|
{campaign.recipients > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 ml-1">
|
||||||
|
({((campaign.conversions / campaign.recipients) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Ingresos</p>
|
||||||
|
<p className="text-lg font-semibold text-green-600">
|
||||||
|
{campaign.revenue}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Campaigns;
|
||||||
393
src/pages/dashboard/crm/Contacts.tsx
Normal file
393
src/pages/dashboard/crm/Contacts.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Star,
|
||||||
|
DollarSign,
|
||||||
|
MoreVertical,
|
||||||
|
Download,
|
||||||
|
Upload
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
firstName: z.string().trim().min(1, 'Nombre requerido').max(50, 'Nombre muy largo'),
|
||||||
|
lastName: z.string().trim().min(1, 'Apellido requerido').max(50, 'Apellido muy largo'),
|
||||||
|
email: z.string().trim().email('Email inválido').max(255, 'Email muy largo'),
|
||||||
|
phone: z.string().trim().max(20, 'Teléfono muy largo').optional(),
|
||||||
|
segment: z.string().min(1, 'Selecciona un segmento'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const Contacts = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedSegment, setSelectedSegment] = useState('all');
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
segment: ''
|
||||||
|
});
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const contacts = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'María González',
|
||||||
|
email: 'maria.gonzalez@email.com',
|
||||||
|
phone: '+1 234 567 8900',
|
||||||
|
segment: 'VIP Travelers',
|
||||||
|
location: 'Madrid, España',
|
||||||
|
lastContact: '2024-03-15',
|
||||||
|
totalSpent: '$2,450',
|
||||||
|
rating: 5,
|
||||||
|
status: 'active'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Juan Pérez',
|
||||||
|
email: 'juan.perez@email.com',
|
||||||
|
phone: '+1 234 567 8901',
|
||||||
|
segment: 'Business',
|
||||||
|
location: 'Barcelona, España',
|
||||||
|
lastContact: '2024-03-14',
|
||||||
|
totalSpent: '$5,890',
|
||||||
|
rating: 4,
|
||||||
|
status: 'active'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Ana Martínez',
|
||||||
|
email: 'ana.martinez@email.com',
|
||||||
|
phone: '+1 234 567 8902',
|
||||||
|
segment: 'Familias',
|
||||||
|
location: 'Valencia, España',
|
||||||
|
lastContact: '2024-03-10',
|
||||||
|
totalSpent: '$1,230',
|
||||||
|
rating: 5,
|
||||||
|
status: 'inactive'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Carlos López',
|
||||||
|
email: 'carlos.lopez@email.com',
|
||||||
|
phone: '+1 234 567 8903',
|
||||||
|
segment: 'Aventureros',
|
||||||
|
location: 'Sevilla, España',
|
||||||
|
lastContact: '2024-03-12',
|
||||||
|
totalSpent: '$890',
|
||||||
|
rating: 4,
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const segments = ['VIP Travelers', 'Business', 'Familias', 'Aventureros'];
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
contactSchema.parse(formData);
|
||||||
|
setFormErrors({});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Contacto agregado',
|
||||||
|
description: `${formData.firstName} ${formData.lastName} ha sido agregado exitosamente.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
setFormData({ firstName: '', lastName: '', email: '', phone: '', segment: '' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
errors[err.path[0].toString()] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormErrors(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredContacts = contacts.filter(contact => {
|
||||||
|
const matchesSearch = contact.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
contact.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesSegment = selectedSegment === 'all' || contact.segment === selectedSegment;
|
||||||
|
return matchesSearch && matchesSegment;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Contactos</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Gestiona tu base de datos de clientes</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Importar
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nuevo Contacto
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Agregar Nuevo Contacto</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Completa la información del cliente
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="firstName">Nombre *</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||||
|
className={formErrors.firstName ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.firstName && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.firstName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="lastName">Apellido *</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||||
|
className={formErrors.lastName ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.lastName && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.lastName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className={formErrors.email ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.email && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone">Teléfono</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className={formErrors.phone ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{formErrors.phone && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="segment">Segmento *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.segment}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, segment: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={formErrors.segment ? 'border-red-500' : ''}>
|
||||||
|
<SelectValue placeholder="Selecciona un segmento" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<SelectItem key={segment} value={segment}>
|
||||||
|
{segment}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formErrors.segment && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{formErrors.segment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
Agregar Contacto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nombre o email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={selectedSegment} onValueChange={setSelectedSegment}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue placeholder="Filtrar por segmento" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos los segmentos</SelectItem>
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<SelectItem key={segment} value={segment}>
|
||||||
|
{segment}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contacts List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Lista de Contactos</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{filteredContacts.length} contacto{filteredContacts.length !== 1 ? 's' : ''} encontrado{filteredContacts.length !== 1 ? 's' : ''}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
Total: {contacts.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredContacts.map((contact) => (
|
||||||
|
<div key={contact.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center text-white font-semibold text-lg">
|
||||||
|
{contact.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900">{contact.name}</h3>
|
||||||
|
<Badge variant={contact.status === 'active' ? 'default' : 'secondary'}>
|
||||||
|
{contact.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{contact.segment}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<span>{contact.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
<span>{contact.phone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span>{contact.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-1 text-yellow-500">
|
||||||
|
<Star className="h-4 w-4 fill-current" />
|
||||||
|
<span className="font-semibold">{contact.rating}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">Rating</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-gray-900">{contact.totalSpent}</p>
|
||||||
|
<p className="text-sm text-gray-600">Total gastado</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-900">{contact.lastContact}</p>
|
||||||
|
<p className="text-sm text-gray-600">Último contacto</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>Ver Perfil</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Enviar Email</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Editar</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-red-600">Eliminar</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Contacts;
|
||||||
Reference in New Issue
Block a user