Clean frontend
This commit is contained in:
@@ -1,617 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Button, Badge, Select, DatePicker } from '../../ui';
|
||||
import { ChartWidget } from './ChartWidget';
|
||||
import { ReportsTable } from './ReportsTable';
|
||||
import { FilterPanel } from './FilterPanel';
|
||||
import { ExportOptions } from './ExportOptions';
|
||||
import type {
|
||||
BakeryMetrics,
|
||||
AnalyticsReport,
|
||||
ChartWidget as ChartWidgetType,
|
||||
FilterPanel as FilterPanelType,
|
||||
AppliedFilter,
|
||||
TimeRange,
|
||||
ExportFormat
|
||||
} from './types';
|
||||
|
||||
interface AnalyticsDashboardProps {
|
||||
className?: string;
|
||||
initialTimeRange?: TimeRange;
|
||||
showFilters?: boolean;
|
||||
showExport?: boolean;
|
||||
customCharts?: ChartWidgetType[];
|
||||
onMetricsLoad?: (metrics: BakeryMetrics) => void;
|
||||
onExport?: (format: ExportFormat, options: any) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_TIME_RANGES: { value: TimeRange; label: string }[] = [
|
||||
{ value: 'today', label: 'Hoy' },
|
||||
{ value: 'yesterday', label: 'Ayer' },
|
||||
{ value: 'thisWeek', label: 'Esta semana' },
|
||||
{ value: 'lastWeek', label: 'Semana pasada' },
|
||||
{ value: 'thisMonth', label: 'Este mes' },
|
||||
{ value: 'lastMonth', label: 'Mes pasado' },
|
||||
{ value: 'thisQuarter', label: 'Este trimestre' },
|
||||
{ value: 'thisYear', label: 'Este año' },
|
||||
{ value: 'custom', label: 'Personalizado' },
|
||||
];
|
||||
|
||||
export const AnalyticsDashboard: React.FC<AnalyticsDashboardProps> = ({
|
||||
className = '',
|
||||
initialTimeRange = 'thisMonth',
|
||||
showFilters = true,
|
||||
showExport = true,
|
||||
customCharts = [],
|
||||
onMetricsLoad,
|
||||
onExport,
|
||||
}) => {
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState<TimeRange>(initialTimeRange);
|
||||
const [customDateRange, setCustomDateRange] = useState<{ from: Date; to: Date } | null>(null);
|
||||
const [bakeryMetrics, setBakeryMetrics] = useState<BakeryMetrics | null>(null);
|
||||
const [reports, setReports] = useState<AnalyticsReport[]>([]);
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilter[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeView, setActiveView] = useState<'overview' | 'sales' | 'production' | 'inventory' | 'financial' | 'reports'>('overview');
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [selectedMetricCards, setSelectedMetricCards] = useState<string[]>([]);
|
||||
|
||||
const loadAnalyticsData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Mock data - in real implementation, this would fetch from API
|
||||
const mockMetrics: BakeryMetrics = {
|
||||
sales: {
|
||||
total_revenue: 45250.75,
|
||||
total_orders: 1247,
|
||||
average_order_value: 36.32,
|
||||
revenue_growth: 12.5,
|
||||
order_growth: 8.3,
|
||||
conversion_rate: 67.8,
|
||||
top_products: [
|
||||
{
|
||||
product_id: 'prod-1',
|
||||
product_name: 'Pan de Molde Integral',
|
||||
category: 'Pan',
|
||||
quantity_sold: 342,
|
||||
revenue: 8540.50,
|
||||
profit_margin: 45.2,
|
||||
growth_rate: 15.3,
|
||||
stock_turns: 12.4
|
||||
},
|
||||
{
|
||||
product_id: 'prod-2',
|
||||
product_name: 'Croissant Mantequilla',
|
||||
category: 'Bollería',
|
||||
quantity_sold: 289,
|
||||
revenue: 7225.00,
|
||||
profit_margin: 52.1,
|
||||
growth_rate: 22.7,
|
||||
stock_turns: 8.9
|
||||
}
|
||||
],
|
||||
sales_by_channel: [
|
||||
{
|
||||
channel: 'Tienda Física',
|
||||
revenue: 28500.45,
|
||||
orders: 789,
|
||||
customers: 634,
|
||||
conversion_rate: 72.1,
|
||||
average_order_value: 36.12,
|
||||
growth_rate: 8.5
|
||||
},
|
||||
{
|
||||
channel: 'Online',
|
||||
revenue: 16750.30,
|
||||
orders: 458,
|
||||
customers: 412,
|
||||
conversion_rate: 61.3,
|
||||
average_order_value: 36.58,
|
||||
growth_rate: 18.9
|
||||
}
|
||||
],
|
||||
hourly_sales: [],
|
||||
seasonal_trends: []
|
||||
},
|
||||
production: {
|
||||
total_batches: 156,
|
||||
success_rate: 94.2,
|
||||
waste_percentage: 3.8,
|
||||
production_efficiency: 87.5,
|
||||
quality_score: 92.1,
|
||||
capacity_utilization: 76.3,
|
||||
production_costs: 18750.25,
|
||||
batch_cycle_time: 4.2,
|
||||
top_recipes: [
|
||||
{
|
||||
recipe_id: 'rec-1',
|
||||
recipe_name: 'Pan de Molde Tradicional',
|
||||
batches_produced: 45,
|
||||
success_rate: 96.8,
|
||||
average_yield: 98.2,
|
||||
cost_per_unit: 1.25,
|
||||
quality_score: 94.5,
|
||||
profitability: 65.2
|
||||
}
|
||||
],
|
||||
quality_trends: []
|
||||
},
|
||||
inventory: {
|
||||
total_stock_value: 12350.80,
|
||||
turnover_rate: 8.4,
|
||||
days_of_inventory: 12.5,
|
||||
stockout_rate: 2.1,
|
||||
waste_value: 245.30,
|
||||
carrying_costs: 1850.75,
|
||||
reorder_efficiency: 89.3,
|
||||
supplier_performance: [
|
||||
{
|
||||
supplier_id: 'sup-1',
|
||||
supplier_name: 'Harinas Premium SA',
|
||||
on_time_delivery: 94.2,
|
||||
quality_rating: 4.6,
|
||||
cost_competitiveness: 87.5,
|
||||
total_orders: 24,
|
||||
total_value: 8450.75,
|
||||
reliability_score: 91.8
|
||||
}
|
||||
],
|
||||
stock_levels: [],
|
||||
expiry_alerts: []
|
||||
},
|
||||
financial: {
|
||||
gross_profit: 26500.25,
|
||||
net_profit: 18750.30,
|
||||
profit_margin: 41.4,
|
||||
cost_of_goods_sold: 18750.50,
|
||||
operating_expenses: 7750.95,
|
||||
break_even_point: 28500.00,
|
||||
cash_flow: 15250.80,
|
||||
roi: 23.8,
|
||||
expense_breakdown: [
|
||||
{
|
||||
category: 'Ingredientes',
|
||||
amount: 12500.25,
|
||||
percentage: 66.7,
|
||||
change_from_previous: 5.2,
|
||||
budget_variance: -2.1
|
||||
},
|
||||
{
|
||||
category: 'Personal',
|
||||
amount: 4250.25,
|
||||
percentage: 22.7,
|
||||
change_from_previous: 8.5,
|
||||
budget_variance: 3.2
|
||||
},
|
||||
{
|
||||
category: 'Suministros',
|
||||
amount: 2000.00,
|
||||
percentage: 10.6,
|
||||
change_from_previous: -1.5,
|
||||
budget_variance: -0.8
|
||||
}
|
||||
],
|
||||
profit_trends: []
|
||||
},
|
||||
customer: {
|
||||
total_customers: 1524,
|
||||
new_customers: 234,
|
||||
returning_customers: 1290,
|
||||
customer_retention_rate: 84.6,
|
||||
customer_lifetime_value: 285.40,
|
||||
average_purchase_frequency: 2.8,
|
||||
customer_satisfaction: 4.6,
|
||||
customer_segments: [
|
||||
{
|
||||
segment_id: 'seg-1',
|
||||
segment_name: 'Clientes Frecuentes',
|
||||
customer_count: 456,
|
||||
revenue_contribution: 68.2,
|
||||
average_order_value: 42.50,
|
||||
purchase_frequency: 5.2,
|
||||
retention_rate: 92.3,
|
||||
growth_rate: 12.8
|
||||
}
|
||||
],
|
||||
loyalty_program_performance: []
|
||||
},
|
||||
operational: {
|
||||
staff_productivity: 87.5,
|
||||
equipment_uptime: 94.2,
|
||||
energy_consumption: 2450.75,
|
||||
delivery_performance: 91.8,
|
||||
food_safety_score: 98.5,
|
||||
compliance_rate: 96.2,
|
||||
maintenance_costs: 1250.30,
|
||||
operational_efficiency: 89.4
|
||||
}
|
||||
};
|
||||
|
||||
setBakeryMetrics(mockMetrics);
|
||||
if (onMetricsLoad) {
|
||||
onMetricsLoad(mockMetrics);
|
||||
}
|
||||
|
||||
// Mock reports
|
||||
const mockReports: AnalyticsReport[] = [
|
||||
{
|
||||
id: 'rep-1',
|
||||
name: 'Reporte de Ventas Mensual',
|
||||
description: 'Análisis completo de ventas del mes',
|
||||
type: 'sales',
|
||||
category: 'Ventas',
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
tenant_id: 'tenant-1',
|
||||
config: {
|
||||
data_source: 'sales_db',
|
||||
refresh_interval: 60,
|
||||
cache_duration: 30,
|
||||
max_records: 10000,
|
||||
default_time_range: 'thisMonth',
|
||||
default_filters: {},
|
||||
visualization_type: 'bar',
|
||||
aggregation_rules: []
|
||||
},
|
||||
metrics: [],
|
||||
filters: [],
|
||||
access_permissions: [],
|
||||
tags: ['ventas', 'mensual']
|
||||
}
|
||||
];
|
||||
|
||||
setReports(mockReports);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTimeRange, customDateRange, appliedFilters, onMetricsLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalyticsData();
|
||||
}, [loadAnalyticsData]);
|
||||
|
||||
const handleTimeRangeChange = (newRange: TimeRange) => {
|
||||
setSelectedTimeRange(newRange);
|
||||
if (newRange !== 'custom') {
|
||||
setCustomDateRange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiltersChange = (filters: AppliedFilter[]) => {
|
||||
setAppliedFilters(filters);
|
||||
};
|
||||
|
||||
const handleExport = (format: ExportFormat, options: any) => {
|
||||
if (onExport) {
|
||||
onExport(format, { ...options, view: activeView, metrics: bakeryMetrics });
|
||||
}
|
||||
setIsExportModalOpen(false);
|
||||
};
|
||||
|
||||
const renderKPICard = (title: string, value: string | number, subtitle?: string, trend?: number, icon?: string, colorClass: string = 'text-blue-600') => (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">{title}</p>
|
||||
<p className={`text-2xl font-bold ${colorClass}`}>{value}</p>
|
||||
{subtitle && <p className="text-sm text-gray-500">{subtitle}</p>}
|
||||
{trend !== undefined && (
|
||||
<div className="flex items-center mt-1">
|
||||
<span className={`text-xs ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend >= 0 ? '↗' : '↘'} {Math.abs(trend).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && <span className="text-2xl">{icon}</span>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderOverviewDashboard = () => {
|
||||
if (!bakeryMetrics) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{renderKPICard(
|
||||
'Ingresos Totales',
|
||||
`€${bakeryMetrics.sales.total_revenue.toLocaleString()}`,
|
||||
undefined,
|
||||
bakeryMetrics.sales.revenue_growth,
|
||||
'💰',
|
||||
'text-green-600'
|
||||
)}
|
||||
{renderKPICard(
|
||||
'Pedidos',
|
||||
bakeryMetrics.sales.total_orders.toLocaleString(),
|
||||
`Ticket medio: €${bakeryMetrics.sales.average_order_value.toFixed(2)}`,
|
||||
bakeryMetrics.sales.order_growth,
|
||||
'📦',
|
||||
'text-blue-600'
|
||||
)}
|
||||
{renderKPICard(
|
||||
'Margen de Beneficio',
|
||||
`${bakeryMetrics.financial.profit_margin.toFixed(1)}%`,
|
||||
`Beneficio: €${bakeryMetrics.financial.net_profit.toLocaleString()}`,
|
||||
undefined,
|
||||
'📈',
|
||||
'text-purple-600'
|
||||
)}
|
||||
{renderKPICard(
|
||||
'Tasa de Éxito Producción',
|
||||
`${bakeryMetrics.production.success_rate.toFixed(1)}%`,
|
||||
`${bakeryMetrics.production.total_batches} lotes`,
|
||||
undefined,
|
||||
'🏭',
|
||||
'text-orange-600'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Ventas por Canal</h3>
|
||||
<div className="space-y-3">
|
||||
{bakeryMetrics.sales.sales_by_channel.map((channel, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{channel.channel}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{channel.orders} pedidos • {channel.customers} clientes
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-green-600">
|
||||
€{channel.revenue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Conv. {channel.conversion_rate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Productos Top</h3>
|
||||
<div className="space-y-3">
|
||||
{bakeryMetrics.sales.top_products.map((product, index) => (
|
||||
<div key={product.product_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{product.product_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{product.category} • {product.quantity_sold} vendidos
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-green-600">
|
||||
€{product.revenue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Margen {product.profit_margin.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Operational Metrics */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Métricas Operacionales</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{bakeryMetrics.operational.staff_productivity.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Productividad Personal</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{bakeryMetrics.operational.equipment_uptime.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Tiempo Activo Equipos</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{bakeryMetrics.production.waste_percentage.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Desperdicio</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-purple-600">
|
||||
{bakeryMetrics.operational.food_safety_score.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Seguridad Alimentaria</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{bakeryMetrics.inventory.stockout_rate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Roturas Stock</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-indigo-600">
|
||||
{bakeryMetrics.customer.customer_retention_rate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Retención Clientes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-pink-600">
|
||||
€{bakeryMetrics.customer.customer_lifetime_value.toFixed(0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Valor Cliente</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-teal-600">
|
||||
{bakeryMetrics.inventory.turnover_rate.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Rotación Inventario</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderViewContent = () => {
|
||||
switch (activeView) {
|
||||
case 'overview':
|
||||
return renderOverviewDashboard();
|
||||
case 'reports':
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<ReportsTable
|
||||
reports={reports}
|
||||
loading={loading}
|
||||
onReportClick={(report) => console.log('View report:', report)}
|
||||
onEditReport={(report) => console.log('Edit report:', report)}
|
||||
onDeleteReport={(id) => console.log('Delete report:', id)}
|
||||
onExportReport={(report, format) => console.log('Export report:', report, format)}
|
||||
bulkActions={true}
|
||||
sortable={true}
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: reports.length,
|
||||
onChange: (page, pageSize) => console.log('Page change:', page, pageSize)
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-500">Vista en desarrollo: {activeView}</p>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Esta vista estará disponible próximamente con métricas detalladas.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
|
||||
<p className="text-gray-600">Análisis integral de rendimiento de la panadería</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={selectedTimeRange}
|
||||
onChange={(e) => handleTimeRangeChange(e.target.value as TimeRange)}
|
||||
className="w-48"
|
||||
>
|
||||
{DEFAULT_TIME_RANGES.map((range) => (
|
||||
<option key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{selectedTimeRange === 'custom' && (
|
||||
<div className="flex gap-2">
|
||||
<DatePicker
|
||||
value={customDateRange?.from}
|
||||
onChange={(date) => setCustomDateRange(prev => ({
|
||||
...prev,
|
||||
from: date || new Date()
|
||||
} as { from: Date; to: Date }))}
|
||||
placeholder="Fecha inicio"
|
||||
/>
|
||||
<DatePicker
|
||||
value={customDateRange?.to}
|
||||
onChange={(date) => setCustomDateRange(prev => ({
|
||||
...prev,
|
||||
to: date || new Date()
|
||||
} as { from: Date; to: Date }))}
|
||||
placeholder="Fecha fin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showExport && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsExportModalOpen(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
📊 Exportar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadAnalyticsData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '🔄 Cargando...' : '🔄 Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
{ key: 'overview', label: 'Resumen', icon: '📊' },
|
||||
{ key: 'sales', label: 'Ventas', icon: '💰' },
|
||||
{ key: 'production', label: 'Producción', icon: '🏭' },
|
||||
{ key: 'inventory', label: 'Inventario', icon: '📦' },
|
||||
{ key: 'financial', label: 'Financiero', icon: '💳' },
|
||||
{ key: 'reports', label: 'Reportes', icon: '📋' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveView(tab.key as any)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||
activeView === tab.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
renderViewContent()
|
||||
)}
|
||||
|
||||
{/* Export Modal */}
|
||||
{isExportModalOpen && (
|
||||
<ExportOptions
|
||||
type="report"
|
||||
title={`Analytics Dashboard - ${activeView}`}
|
||||
description="Exportar datos del dashboard de analytics"
|
||||
onExport={handleExport}
|
||||
loading={false}
|
||||
showScheduling={true}
|
||||
showTemplates={true}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsDashboard;
|
||||
@@ -1,660 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Select, Modal } from '../../ui';
|
||||
import type {
|
||||
ChartWidget as ChartWidgetType,
|
||||
ChartSeries,
|
||||
ChartConfig,
|
||||
ChartFilter,
|
||||
ExportFormat,
|
||||
ChartType
|
||||
} from './types';
|
||||
|
||||
interface ChartWidgetProps {
|
||||
widget: ChartWidgetType;
|
||||
data?: ChartSeries[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onConfigChange?: (config: Partial<ChartConfig>) => void;
|
||||
onFiltersChange?: (filters: ChartFilter[]) => void;
|
||||
onRefresh?: () => void;
|
||||
onExport?: (format: ExportFormat) => void;
|
||||
onFullscreen?: () => void;
|
||||
interactive?: boolean;
|
||||
showControls?: boolean;
|
||||
showTitle?: boolean;
|
||||
showSubtitle?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CHART_TYPE_LABELS: Record<ChartType, string> = {
|
||||
line: 'Líneas',
|
||||
bar: 'Barras',
|
||||
pie: 'Circular',
|
||||
area: 'Área',
|
||||
scatter: 'Dispersión',
|
||||
doughnut: 'Dona',
|
||||
radar: 'Radar',
|
||||
mixed: 'Mixto',
|
||||
};
|
||||
|
||||
const EXPORT_FORMATS: ExportFormat[] = ['png', 'svg', 'pdf', 'csv', 'excel'];
|
||||
|
||||
export const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||
widget,
|
||||
data = [],
|
||||
loading = false,
|
||||
error,
|
||||
onConfigChange,
|
||||
onFiltersChange,
|
||||
onRefresh,
|
||||
onExport,
|
||||
onFullscreen,
|
||||
interactive = true,
|
||||
showControls = true,
|
||||
showTitle = true,
|
||||
showSubtitle = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<{ seriesIndex: number; pointIndex: number } | null>(null);
|
||||
const [selectedExportFormat, setSelectedExportFormat] = useState<ExportFormat>('png');
|
||||
|
||||
const chartDimensions = useMemo(() => ({
|
||||
width: widget.dimensions?.width || '100%',
|
||||
height: widget.dimensions?.height || 400,
|
||||
aspectRatio: widget.dimensions?.aspect_ratio || '16:9',
|
||||
}), [widget.dimensions]);
|
||||
|
||||
// Simple chart rendering logic
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !data.length || loading) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw based on chart type
|
||||
switch (widget.type) {
|
||||
case 'bar':
|
||||
drawBarChart(ctx, canvas, data);
|
||||
break;
|
||||
case 'line':
|
||||
drawLineChart(ctx, canvas, data);
|
||||
break;
|
||||
case 'pie':
|
||||
drawPieChart(ctx, canvas, data);
|
||||
break;
|
||||
case 'area':
|
||||
drawAreaChart(ctx, canvas, data);
|
||||
break;
|
||||
default:
|
||||
drawBarChart(ctx, canvas, data);
|
||||
}
|
||||
}, [data, widget.type, loading]);
|
||||
|
||||
const drawBarChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
||||
if (!chartData.length) return;
|
||||
|
||||
const padding = 60;
|
||||
const chartWidth = canvas.width - 2 * padding;
|
||||
const chartHeight = canvas.height - 2 * padding;
|
||||
|
||||
// Get all data points
|
||||
const allPoints = chartData.flatMap(series => series.data);
|
||||
const maxY = Math.max(...allPoints.map(point => point.y));
|
||||
const minY = Math.min(...allPoints.map(point => point.y));
|
||||
|
||||
const firstSeries = chartData[0];
|
||||
const barWidth = chartWidth / firstSeries.data.length / chartData.length;
|
||||
const barSpacing = barWidth * 0.1;
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (chartHeight * i) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, y);
|
||||
ctx.lineTo(padding + chartWidth, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw bars
|
||||
chartData.forEach((series, seriesIndex) => {
|
||||
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
|
||||
ctx.fillStyle = color;
|
||||
|
||||
series.data.forEach((point, pointIndex) => {
|
||||
const x = padding + (pointIndex * chartWidth) / firstSeries.data.length + (seriesIndex * barWidth);
|
||||
const barHeight = ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
const y = padding + chartHeight - barHeight;
|
||||
|
||||
ctx.fillRect(x + barSpacing, y, barWidth - barSpacing, barHeight);
|
||||
|
||||
// Add value labels if enabled
|
||||
if (widget.config.show_tooltips) {
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(point.y.toString(), x + barWidth / 2, y - 5);
|
||||
ctx.fillStyle = color;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Draw axes
|
||||
ctx.strokeStyle = '#374151';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, padding + chartHeight);
|
||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw labels
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// X-axis labels
|
||||
firstSeries.data.forEach((point, index) => {
|
||||
const x = padding + (index + 0.5) * (chartWidth / firstSeries.data.length);
|
||||
ctx.fillText(point.x.toString(), x, padding + chartHeight + 20);
|
||||
});
|
||||
|
||||
// Y-axis labels
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (chartHeight * i) / 5;
|
||||
const value = maxY - ((maxY - minY) * i) / 5;
|
||||
ctx.fillText(value.toFixed(0), padding - 10, y + 4);
|
||||
}
|
||||
};
|
||||
|
||||
const drawLineChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
||||
if (!chartData.length) return;
|
||||
|
||||
const padding = 60;
|
||||
const chartWidth = canvas.width - 2 * padding;
|
||||
const chartHeight = canvas.height - 2 * padding;
|
||||
|
||||
const allPoints = chartData.flatMap(series => series.data);
|
||||
const maxY = Math.max(...allPoints.map(point => point.y));
|
||||
const minY = Math.min(...allPoints.map(point => point.y));
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (chartHeight * i) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, y);
|
||||
ctx.lineTo(padding + chartWidth, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw lines
|
||||
chartData.forEach((series, seriesIndex) => {
|
||||
if (!series.visible) return;
|
||||
|
||||
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
series.data.forEach((point, pointIndex) => {
|
||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
if (pointIndex === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// Draw points
|
||||
ctx.fillStyle = color;
|
||||
series.data.forEach((point, pointIndex) => {
|
||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
});
|
||||
});
|
||||
|
||||
// Draw axes
|
||||
ctx.strokeStyle = '#374151';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, padding + chartHeight);
|
||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const drawPieChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
||||
if (!chartData.length) return;
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const radius = Math.min(canvas.width, canvas.height) / 3;
|
||||
|
||||
const firstSeries = chartData[0];
|
||||
const total = firstSeries.data.reduce((sum, point) => sum + point.y, 0);
|
||||
|
||||
let startAngle = -Math.PI / 2;
|
||||
|
||||
firstSeries.data.forEach((point, index) => {
|
||||
const sliceAngle = (point.y / total) * 2 * Math.PI;
|
||||
const color = widget.config.color_scheme[index % widget.config.color_scheme.length];
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw slice border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw labels
|
||||
const labelAngle = startAngle + sliceAngle / 2;
|
||||
const labelX = centerX + Math.cos(labelAngle) * (radius + 30);
|
||||
const labelY = centerY + Math.sin(labelAngle) * (radius + 30);
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(point.x.toString(), labelX, labelY);
|
||||
|
||||
const percentage = ((point.y / total) * 100).toFixed(1);
|
||||
ctx.fillText(`${percentage}%`, labelX, labelY + 15);
|
||||
|
||||
startAngle += sliceAngle;
|
||||
});
|
||||
};
|
||||
|
||||
const drawAreaChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
||||
if (!chartData.length) return;
|
||||
|
||||
const padding = 60;
|
||||
const chartWidth = canvas.width - 2 * padding;
|
||||
const chartHeight = canvas.height - 2 * padding;
|
||||
|
||||
const allPoints = chartData.flatMap(series => series.data);
|
||||
const maxY = Math.max(...allPoints.map(point => point.y));
|
||||
const minY = Math.min(...allPoints.map(point => point.y));
|
||||
|
||||
chartData.forEach((series, seriesIndex) => {
|
||||
if (!series.visible) return;
|
||||
|
||||
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(0, padding, 0, padding + chartHeight);
|
||||
gradient.addColorStop(0, color + '80'); // Semi-transparent
|
||||
gradient.addColorStop(1, color + '10'); // Very transparent
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
|
||||
// Start from bottom-left
|
||||
const firstX = padding;
|
||||
const firstY = padding + chartHeight;
|
||||
ctx.moveTo(firstX, firstY);
|
||||
|
||||
// Draw the area path
|
||||
series.data.forEach((point, pointIndex) => {
|
||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
// Close the path at bottom-right
|
||||
const lastX = padding + chartWidth;
|
||||
const lastY = padding + chartHeight;
|
||||
ctx.lineTo(lastX, lastY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw the line on top
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
series.data.forEach((point, pointIndex) => {
|
||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
if (pointIndex === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (onExport) {
|
||||
onExport(selectedExportFormat);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
if (onFullscreen) {
|
||||
onFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const renderLegend = () => {
|
||||
if (!widget.config.show_legend || !data.length) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
{data.map((series, index) => (
|
||||
<div key={series.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: series.color || widget.config.color_scheme[index % widget.config.color_scheme.length]
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{series.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderControls = () => {
|
||||
if (!showControls || !interactive) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsConfigModalOpen(true)}
|
||||
title="Configurar gráfico"
|
||||
>
|
||||
⚙️
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
value={selectedExportFormat}
|
||||
onChange={(e) => setSelectedExportFormat(e.target.value as ExportFormat)}
|
||||
className="w-20 text-xs"
|
||||
>
|
||||
{EXPORT_FORMATS.map(format => (
|
||||
<option key={format} value={format}>{format.toUpperCase()}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
title="Exportar gráfico"
|
||||
>
|
||||
📥
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleFullscreen}
|
||||
title="Pantalla completa"
|
||||
>
|
||||
{isFullscreen ? '📋' : '🔍'}
|
||||
</Button>
|
||||
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Actualizar datos"
|
||||
>
|
||||
🔄
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 font-medium">Error al cargar el gráfico</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{error}</p>
|
||||
{onRefresh && (
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} className="mt-3">
|
||||
Reintentar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`group relative ${className}`} style={{
|
||||
width: chartDimensions.width,
|
||||
height: isFullscreen ? '90vh' : chartDimensions.height
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 pb-2">
|
||||
<div className="flex-1">
|
||||
{showTitle && (
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{widget.title}
|
||||
</h3>
|
||||
)}
|
||||
{showSubtitle && widget.subtitle && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{widget.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderControls()}
|
||||
</div>
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="px-4 pb-4" style={{ height: `calc(100% - 80px)` }}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">No hay datos disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Ajusta los filtros o verifica la conexión de datos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
style={{ maxHeight: '100%' }}
|
||||
/>
|
||||
{renderLegend()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Updated */}
|
||||
{widget.last_updated && (
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
||||
Actualizado: {new Date(widget.last_updated).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Configuration Modal */}
|
||||
<Modal
|
||||
isOpen={isConfigModalOpen}
|
||||
onClose={() => setIsConfigModalOpen(false)}
|
||||
title="Configuración del Gráfico"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de gráfico
|
||||
</label>
|
||||
<Select
|
||||
value={widget.type}
|
||||
onChange={(e) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ visualization_type: e.target.value as ChartType });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.entries(CHART_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={widget.config.show_legend}
|
||||
onChange={(e) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ show_legend: e.target.checked });
|
||||
}
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
Mostrar leyenda
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={widget.config.show_grid}
|
||||
onChange={(e) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ show_grid: e.target.checked });
|
||||
}
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
Mostrar cuadrícula
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={widget.config.show_tooltips}
|
||||
onChange={(e) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ show_tooltips: e.target.checked });
|
||||
}
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
Mostrar tooltips
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={widget.config.animation_enabled}
|
||||
onChange={(e) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ animation_enabled: e.target.checked });
|
||||
}
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
Animaciones
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Posición de la leyenda
|
||||
</label>
|
||||
<Select
|
||||
value={widget.config.legend_position}
|
||||
onChange={(e) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ legend_position: e.target.value as any });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="top">Arriba</option>
|
||||
<option value="bottom">Abajo</option>
|
||||
<option value="left">Izquierda</option>
|
||||
<option value="right">Derecha</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsConfigModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setIsConfigModalOpen(false);
|
||||
if (onRefresh) onRefresh();
|
||||
}}
|
||||
>
|
||||
Aplicar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartWidget;
|
||||
@@ -1,592 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Button, Badge, Input, Select } from '../../ui';
|
||||
import type {
|
||||
ExportOptionsProps,
|
||||
ExportOptions,
|
||||
ExportFormat,
|
||||
ExportTemplate,
|
||||
ReportSchedule
|
||||
} from './types';
|
||||
|
||||
const FORMAT_LABELS: Record<ExportFormat, string> = {
|
||||
pdf: 'PDF',
|
||||
excel: 'Excel',
|
||||
csv: 'CSV',
|
||||
png: 'PNG',
|
||||
svg: 'SVG',
|
||||
json: 'JSON',
|
||||
};
|
||||
|
||||
const FORMAT_DESCRIPTIONS: Record<ExportFormat, string> = {
|
||||
pdf: 'Documento PDF con formato profesional',
|
||||
excel: 'Hoja de cálculo de Microsoft Excel',
|
||||
csv: 'Archivo de valores separados por comas',
|
||||
png: 'Imagen PNG de alta calidad',
|
||||
svg: 'Imagen vectorial escalable',
|
||||
json: 'Datos estructurados en formato JSON',
|
||||
};
|
||||
|
||||
const FORMAT_ICONS: Record<ExportFormat, string> = {
|
||||
pdf: '📄',
|
||||
excel: '📊',
|
||||
csv: '📋',
|
||||
png: '🖼️',
|
||||
svg: '🎨',
|
||||
json: '⚙️',
|
||||
};
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 'daily', label: 'Diario' },
|
||||
{ value: 'weekly', label: 'Semanal' },
|
||||
{ value: 'monthly', label: 'Mensual' },
|
||||
{ value: 'quarterly', label: 'Trimestral' },
|
||||
{ value: 'yearly', label: 'Anual' },
|
||||
];
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
{ value: '06:00', label: '6:00 AM' },
|
||||
{ value: '09:00', label: '9:00 AM' },
|
||||
{ value: '12:00', label: '12:00 PM' },
|
||||
{ value: '15:00', label: '3:00 PM' },
|
||||
{ value: '18:00', label: '6:00 PM' },
|
||||
{ value: '21:00', label: '9:00 PM' },
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 1, label: 'Lunes' },
|
||||
{ value: 2, label: 'Martes' },
|
||||
{ value: 3, label: 'Miércoles' },
|
||||
{ value: 4, label: 'Jueves' },
|
||||
{ value: 5, label: 'Viernes' },
|
||||
{ value: 6, label: 'Sábado' },
|
||||
{ value: 0, label: 'Domingo' },
|
||||
];
|
||||
|
||||
interface ExportOptionsComponentProps extends ExportOptionsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ExportOptions: React.FC<ExportOptionsComponentProps> = ({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
availableFormats = ['pdf', 'excel', 'csv', 'png'],
|
||||
templates = [],
|
||||
onExport,
|
||||
onSchedule,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
showScheduling = true,
|
||||
showTemplates = true,
|
||||
showAdvanced = true,
|
||||
defaultOptions,
|
||||
className = '',
|
||||
onClose,
|
||||
}) => {
|
||||
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>(
|
||||
defaultOptions?.format || availableFormats[0]
|
||||
);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(
|
||||
templates.find(t => t.is_default)?.id || ''
|
||||
);
|
||||
const [exportOptions, setExportOptions] = useState<Partial<ExportOptions>>({
|
||||
include_headers: true,
|
||||
include_filters: true,
|
||||
include_summary: true,
|
||||
date_format: 'DD/MM/YYYY',
|
||||
number_format: '#,##0.00',
|
||||
currency_format: '€#,##0.00',
|
||||
locale: 'es-ES',
|
||||
timezone: 'Europe/Madrid',
|
||||
page_size: 'A4',
|
||||
orientation: 'portrait',
|
||||
password_protected: false,
|
||||
...defaultOptions,
|
||||
});
|
||||
|
||||
const [scheduleOptions, setScheduleOptions] = useState<Partial<ReportSchedule>>({
|
||||
enabled: false,
|
||||
frequency: 'weekly',
|
||||
time: '09:00',
|
||||
days_of_week: [1], // Monday
|
||||
recipients: [],
|
||||
format: selectedFormat,
|
||||
include_attachments: true,
|
||||
});
|
||||
|
||||
const [recipientsInput, setRecipientsInput] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'export' | 'schedule'>('export');
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
const handleExportOptionChange = (key: keyof ExportOptions, value: any) => {
|
||||
setExportOptions(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleScheduleOptionChange = (key: keyof ReportSchedule, value: any) => {
|
||||
setScheduleOptions(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (onExport) {
|
||||
const fullOptions: ExportOptions = {
|
||||
format: selectedFormat,
|
||||
template: selectedTemplate || undefined,
|
||||
include_headers: true,
|
||||
include_filters: true,
|
||||
include_summary: true,
|
||||
date_format: 'DD/MM/YYYY',
|
||||
number_format: '#,##0.00',
|
||||
currency_format: '€#,##0.00',
|
||||
locale: 'es-ES',
|
||||
timezone: 'Europe/Madrid',
|
||||
page_size: 'A4',
|
||||
orientation: 'portrait',
|
||||
...exportOptions,
|
||||
// format: selectedFormat, // Ensure format matches selection - handled by exportOptions
|
||||
};
|
||||
onExport(fullOptions);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedule = () => {
|
||||
if (onSchedule) {
|
||||
const recipients = recipientsInput
|
||||
.split(',')
|
||||
.map(email => email.trim())
|
||||
.filter(email => email.length > 0);
|
||||
|
||||
const fullSchedule: ReportSchedule = {
|
||||
enabled: true,
|
||||
frequency: 'weekly',
|
||||
time: '09:00',
|
||||
recipients: recipients,
|
||||
format: selectedFormat,
|
||||
include_attachments: true,
|
||||
...scheduleOptions,
|
||||
};
|
||||
onSchedule(fullSchedule);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormatSelector = () => (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">Formato de exportación</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{availableFormats.map(format => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => setSelectedFormat(format)}
|
||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||
selectedFormat === format
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xl">{FORMAT_ICONS[format]}</span>
|
||||
<span className="font-medium">{FORMAT_LABELS[format]}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{FORMAT_DESCRIPTIONS[format]}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTemplateSelector = () => {
|
||||
if (!showTemplates || templates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">Plantilla</h4>
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<option value="">Plantilla predeterminada</option>
|
||||
{templates.map(template => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
{template.is_default && ' (Predeterminada)'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{selectedTemplate && (
|
||||
<div className="text-sm text-gray-600">
|
||||
{templates.find(t => t.id === selectedTemplate)?.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBasicOptions = () => (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">Opciones básicas</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportOptions.include_headers || false}
|
||||
onChange={(e) => handleExportOptionChange('include_headers', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
Incluir encabezados de columna
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportOptions.include_filters || false}
|
||||
onChange={(e) => handleExportOptionChange('include_filters', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
Incluir información de filtros aplicados
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportOptions.include_summary || false}
|
||||
onChange={(e) => handleExportOptionChange('include_summary', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
Incluir resumen y estadísticas
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAdvancedOptions = () => {
|
||||
if (!showAdvanced || !showAdvancedOptions) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="font-medium text-gray-900">Opciones avanzadas</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Formato de fecha
|
||||
</label>
|
||||
<Select
|
||||
value={exportOptions.date_format || 'DD/MM/YYYY'}
|
||||
onChange={(e) => handleExportOptionChange('date_format', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
||||
<option value="DD-MMM-YYYY">DD-MMM-YYYY</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Formato de números
|
||||
</label>
|
||||
<Select
|
||||
value={exportOptions.number_format || '#,##0.00'}
|
||||
onChange={(e) => handleExportOptionChange('number_format', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<option value="#,##0.00">#,##0.00</option>
|
||||
<option value="#.##0,00">#.##0,00</option>
|
||||
<option value="#,##0">#,##0</option>
|
||||
<option value="0.00">0.00</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(['pdf'] as ExportFormat[]).includes(selectedFormat) && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tamaño de página
|
||||
</label>
|
||||
<Select
|
||||
value={exportOptions.page_size || 'A4'}
|
||||
onChange={(e) => handleExportOptionChange('page_size', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<option value="A4">A4</option>
|
||||
<option value="A3">A3</option>
|
||||
<option value="Letter">Carta</option>
|
||||
<option value="Legal">Legal</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Orientación
|
||||
</label>
|
||||
<Select
|
||||
value={exportOptions.orientation || 'portrait'}
|
||||
onChange={(e) => handleExportOptionChange('orientation', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<option value="portrait">Vertical</option>
|
||||
<option value="landscape">Horizontal</option>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportOptions.password_protected || false}
|
||||
onChange={(e) => handleExportOptionChange('password_protected', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
Proteger con contraseña
|
||||
</label>
|
||||
|
||||
{exportOptions.password_protected && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Contraseña"
|
||||
value={exportOptions.password || ''}
|
||||
onChange={(e) => handleExportOptionChange('password', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScheduleTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scheduleOptions.enabled || false}
|
||||
onChange={(e) => handleScheduleOptionChange('enabled', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
<span className="font-medium text-gray-900">Habilitar programación automática</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{scheduleOptions.enabled && (
|
||||
<div className="space-y-4 pl-6 border-l-2 border-blue-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Frecuencia
|
||||
</label>
|
||||
<Select
|
||||
value={scheduleOptions.frequency || 'weekly'}
|
||||
onChange={(e) => handleScheduleOptionChange('frequency', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{FREQUENCY_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Hora de envío
|
||||
</label>
|
||||
<Select
|
||||
value={scheduleOptions.time || '09:00'}
|
||||
onChange={(e) => handleScheduleOptionChange('time', e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{TIME_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleOptions.frequency === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Días de la semana
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(day => (
|
||||
<label key={day.value} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(scheduleOptions.days_of_week || []).includes(day.value)}
|
||||
onChange={(e) => {
|
||||
const currentDays = scheduleOptions.days_of_week || [];
|
||||
const newDays = e.target.checked
|
||||
? [...currentDays, day.value]
|
||||
: currentDays.filter(d => d !== day.value);
|
||||
handleScheduleOptionChange('days_of_week', newDays);
|
||||
}}
|
||||
className="mr-1 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
<span className="text-sm">{day.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Destinatarios (emails separados por comas)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={recipientsInput}
|
||||
onChange={(e) => setRecipientsInput(e.target.value)}
|
||||
placeholder="ejemplo@empresa.com, otro@empresa.com"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scheduleOptions.include_attachments || false}
|
||||
onChange={(e) => handleScheduleOptionChange('include_attachments', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
Incluir archivo adjunto con los datos
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-lg max-w-2xl mx-auto ${className}`}>
|
||||
<Card className="p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('export')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'export'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
📥 Exportación inmediata
|
||||
</button>
|
||||
|
||||
{showScheduling && (
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'schedule'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
📅 Programación
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
{activeTab === 'export' ? (
|
||||
<>
|
||||
{renderFormatSelector()}
|
||||
{renderTemplateSelector()}
|
||||
{renderBasicOptions()}
|
||||
|
||||
{showAdvanced && (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
className="text-blue-600"
|
||||
>
|
||||
{showAdvancedOptions ? '▲' : '▼'} Opciones avanzadas
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderAdvancedOptions()}
|
||||
</>
|
||||
) : (
|
||||
renderScheduleTab()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-200">
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeTab === 'export' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleExport}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? '🔄 Exportando...' : `${FORMAT_ICONS[selectedFormat]} Exportar ${FORMAT_LABELS[selectedFormat]}`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSchedule}
|
||||
disabled={disabled || loading || !scheduleOptions.enabled || !recipientsInput.trim()}
|
||||
>
|
||||
{loading ? '🔄 Programando...' : '📅 Programar reporte'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportOptions;
|
||||
@@ -1,632 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Input, Select, DatePicker } from '../../ui';
|
||||
import type {
|
||||
FilterPanelProps,
|
||||
AnalyticsFilter,
|
||||
FilterOption,
|
||||
AppliedFilter,
|
||||
FilterPreset,
|
||||
FilterType
|
||||
} from './types';
|
||||
|
||||
const FILTER_TYPE_ICONS: Record<FilterType, string> = {
|
||||
date: '📅',
|
||||
select: '📋',
|
||||
multiselect: '☑️',
|
||||
range: '↔️',
|
||||
text: '🔍',
|
||||
number: '#️⃣',
|
||||
boolean: '✓',
|
||||
};
|
||||
|
||||
export const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
panel,
|
||||
appliedFilters = [],
|
||||
onFiltersChange,
|
||||
onPresetSelect,
|
||||
onPresetSave,
|
||||
onPresetDelete,
|
||||
onReset,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
showPresets = true,
|
||||
showReset = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(panel.default_collapsed);
|
||||
const [localFilters, setLocalFilters] = useState<Record<string, any>>({});
|
||||
const [isPresetModalOpen, setIsPresetModalOpen] = useState(false);
|
||||
const [newPresetName, setNewPresetName] = useState('');
|
||||
const [newPresetDescription, setNewPresetDescription] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const appliedFiltersMap = useMemo(() => {
|
||||
const map: Record<string, AppliedFilter> = {};
|
||||
appliedFilters.forEach(filter => {
|
||||
map[filter.filter_id] = filter;
|
||||
});
|
||||
return map;
|
||||
}, [appliedFilters]);
|
||||
|
||||
const getFilterValue = useCallback((filter: AnalyticsFilter) => {
|
||||
const applied = appliedFiltersMap[filter.id];
|
||||
if (applied) {
|
||||
return applied.value;
|
||||
}
|
||||
|
||||
const local = localFilters[filter.id];
|
||||
if (local !== undefined) {
|
||||
return local;
|
||||
}
|
||||
|
||||
return filter.default_value;
|
||||
}, [appliedFiltersMap, localFilters]);
|
||||
|
||||
const updateFilterValue = useCallback((filterId: string, value: any) => {
|
||||
setLocalFilters(prev => ({ ...prev, [filterId]: value }));
|
||||
|
||||
// Clear validation error for this filter
|
||||
setValidationErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[filterId];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validateFilter = useCallback((filter: AnalyticsFilter, value: any): string | null => {
|
||||
if (!filter.validation_rules) return null;
|
||||
|
||||
for (const rule of filter.validation_rules) {
|
||||
switch (rule.rule) {
|
||||
case 'required':
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
return rule.message;
|
||||
}
|
||||
break;
|
||||
case 'min':
|
||||
if (typeof value === 'number' && value < rule.value) {
|
||||
return rule.message;
|
||||
}
|
||||
if (typeof value === 'string' && value.length < rule.value) {
|
||||
return rule.message;
|
||||
}
|
||||
break;
|
||||
case 'max':
|
||||
if (typeof value === 'number' && value > rule.value) {
|
||||
return rule.message;
|
||||
}
|
||||
if (typeof value === 'string' && value.length > rule.value) {
|
||||
return rule.message;
|
||||
}
|
||||
break;
|
||||
case 'pattern':
|
||||
if (typeof value === 'string' && !new RegExp(rule.value).test(value)) {
|
||||
return rule.message;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const applyFilters = useCallback(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
const newAppliedFilters: AppliedFilter[] = [];
|
||||
|
||||
panel.filters.forEach(filter => {
|
||||
const value = getFilterValue(filter);
|
||||
|
||||
// Validate the filter
|
||||
const validationError = validateFilter(filter, value);
|
||||
if (validationError) {
|
||||
errors[filter.id] = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no value and not required
|
||||
if ((value === undefined || value === null || value === '') && !filter.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create applied filter
|
||||
const appliedFilter: AppliedFilter = {
|
||||
filter_id: filter.id,
|
||||
field: filter.field,
|
||||
operator: getOperatorForFilterType(filter.type),
|
||||
value,
|
||||
display_value: formatDisplayValue(filter, value),
|
||||
};
|
||||
|
||||
newAppliedFilters.push(appliedFilter);
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
|
||||
if (Object.keys(errors).length === 0 && onFiltersChange) {
|
||||
onFiltersChange(newAppliedFilters);
|
||||
}
|
||||
}, [panel.filters, getFilterValue, validateFilter, onFiltersChange]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setLocalFilters({});
|
||||
setValidationErrors({});
|
||||
if (onReset) {
|
||||
onReset();
|
||||
}
|
||||
}, [onReset]);
|
||||
|
||||
const getOperatorForFilterType = (type: FilterType): string => {
|
||||
switch (type) {
|
||||
case 'select':
|
||||
case 'boolean':
|
||||
return 'eq';
|
||||
case 'multiselect':
|
||||
return 'in';
|
||||
case 'range':
|
||||
return 'between';
|
||||
case 'text':
|
||||
return 'like';
|
||||
case 'number':
|
||||
case 'date':
|
||||
return 'eq';
|
||||
default:
|
||||
return 'eq';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDisplayValue = (filter: AnalyticsFilter, value: any): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
|
||||
switch (filter.type) {
|
||||
case 'select':
|
||||
case 'multiselect':
|
||||
if (filter.options) {
|
||||
const options = Array.isArray(value) ? value : [value];
|
||||
return options
|
||||
.map(v => filter.options?.find(opt => opt.value === v)?.label || v)
|
||||
.join(', ');
|
||||
}
|
||||
return Array.isArray(value) ? value.join(', ') : String(value);
|
||||
case 'boolean':
|
||||
return value ? 'Sí' : 'No';
|
||||
case 'date':
|
||||
return new Date(value).toLocaleDateString('es-ES');
|
||||
case 'range':
|
||||
return Array.isArray(value) ? `${value[0]} - ${value[1]}` : String(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
const preset = panel.presets.find(p => p.id === presetId);
|
||||
if (preset) {
|
||||
setLocalFilters(preset.values);
|
||||
if (onPresetSelect) {
|
||||
onPresetSelect(presetId);
|
||||
}
|
||||
// Auto-apply filters when preset is selected
|
||||
setTimeout(applyFilters, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSave = () => {
|
||||
if (!newPresetName.trim()) return;
|
||||
|
||||
const currentValues: Record<string, any> = {};
|
||||
panel.filters.forEach(filter => {
|
||||
const value = getFilterValue(filter);
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
currentValues[filter.id] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const newPreset: Omit<FilterPreset, 'id'> = {
|
||||
name: newPresetName.trim(),
|
||||
description: newPresetDescription.trim() || undefined,
|
||||
is_default: false,
|
||||
is_public: false,
|
||||
values: currentValues,
|
||||
};
|
||||
|
||||
if (onPresetSave) {
|
||||
onPresetSave(newPreset);
|
||||
}
|
||||
|
||||
setIsPresetModalOpen(false);
|
||||
setNewPresetName('');
|
||||
setNewPresetDescription('');
|
||||
};
|
||||
|
||||
const renderFilter = (filter: AnalyticsFilter) => {
|
||||
const value = getFilterValue(filter);
|
||||
const error = validationErrors[filter.id];
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
const commonProps = {
|
||||
disabled: isDisabled,
|
||||
className: error ? 'border-red-300 focus:border-red-500' : '',
|
||||
};
|
||||
|
||||
let filterInput: React.ReactNode;
|
||||
|
||||
switch (filter.type) {
|
||||
case 'text':
|
||||
filterInput = (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={filter.placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => updateFilterValue(filter.id, e.target.value)}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
filterInput = (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={filter.placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => updateFilterValue(filter.id, parseFloat(e.target.value) || null)}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
filterInput = (
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => updateFilterValue(filter.id, e.target.value)}
|
||||
{...commonProps}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{filter.options?.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.icon} {option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'multiselect':
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
filterInput = (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-32 overflow-y-auto border border-gray-300 rounded p-2">
|
||||
{filter.options?.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 p-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={(e) => {
|
||||
const newValues = e.target.checked
|
||||
? [...selectedValues, option.value]
|
||||
: selectedValues.filter(v => v !== option.value);
|
||||
updateFilterValue(filter.id, newValues);
|
||||
}}
|
||||
disabled={isDisabled || option.disabled}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{option.icon} {option.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((val) => {
|
||||
const option = filter.options?.find(opt => opt.value === val);
|
||||
return (
|
||||
<Badge key={val} className="bg-blue-100 text-blue-800 text-xs">
|
||||
{option?.label || val}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newValues = selectedValues.filter(v => v !== val);
|
||||
updateFilterValue(filter.id, newValues);
|
||||
}}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
filterInput = (
|
||||
<Select
|
||||
value={value === true ? 'true' : value === false ? 'false' : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === 'true' ? true : e.target.value === 'false' ? false : null;
|
||||
updateFilterValue(filter.id, val);
|
||||
}}
|
||||
{...commonProps}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="true">Sí</option>
|
||||
<option value="false">No</option>
|
||||
</Select>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
filterInput = (
|
||||
<DatePicker
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={(date) => updateFilterValue(filter.id, date?.toISOString())}
|
||||
placeholder={filter.placeholder}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
const rangeValue = Array.isArray(value) ? value : [null, null];
|
||||
filterInput = (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Mín"
|
||||
value={rangeValue[0] || ''}
|
||||
onChange={(e) => {
|
||||
const newRange = [parseFloat(e.target.value) || null, rangeValue[1]];
|
||||
updateFilterValue(filter.id, newRange);
|
||||
}}
|
||||
{...commonProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-gray-500">-</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Máx"
|
||||
value={rangeValue[1] || ''}
|
||||
onChange={(e) => {
|
||||
const newRange = [rangeValue[0], parseFloat(e.target.value) || null];
|
||||
updateFilterValue(filter.id, newRange);
|
||||
}}
|
||||
{...commonProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
filterInput = (
|
||||
<div className="text-gray-500 text-sm">
|
||||
Tipo de filtro no soportado: {filter.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={filter.id} className={compact ? 'space-y-1' : 'space-y-2'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`font-medium text-gray-700 ${compact ? 'text-sm' : ''}`}>
|
||||
<span className="mr-1">{FILTER_TYPE_ICONS[filter.type]}</span>
|
||||
{filter.display_name}
|
||||
{filter.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{filter.help_text && (
|
||||
<span className="text-xs text-gray-500" title={filter.help_text}>
|
||||
ℹ️
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filterInput}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPresets = () => {
|
||||
if (!showPresets || panel.presets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-700">Filtros guardados</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsPresetModalOpen(true)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
💾 Guardar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{panel.presets.map((preset) => (
|
||||
<div key={preset.id} className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
disabled={disabled || loading}
|
||||
className={panel.active_preset === preset.id ? 'bg-blue-50 border-blue-300' : ''}
|
||||
>
|
||||
{preset.icon && <span className="mr-1">{preset.icon}</span>}
|
||||
{preset.name}
|
||||
</Button>
|
||||
|
||||
{onPresetDelete && !preset.is_public && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPresetDelete(preset.id)}
|
||||
className="text-red-600 hover:text-red-800 p-1"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (panel.collapsible && isCollapsed) {
|
||||
return (
|
||||
<Card className={`p-3 ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="font-semibold text-gray-900">{panel.title}</h3>
|
||||
<span className="text-gray-500">▼</span>
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const gridClass = panel.layout === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 gap-4' :
|
||||
panel.layout === 'horizontal' ? 'grid grid-cols-1 lg:grid-cols-3 gap-4' :
|
||||
'space-y-4';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`p-4 ${className}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">{panel.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
)}
|
||||
|
||||
{panel.collapsible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
>
|
||||
▲
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
{renderPresets()}
|
||||
|
||||
{/* Filters */}
|
||||
<div className={gridClass}>
|
||||
{panel.filters.map(filter => renderFilter(filter))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
{appliedFilters.length} filtro{appliedFilters.length !== 1 ? 's' : ''} aplicado{appliedFilters.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showReset && appliedFilters.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetFilters}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={applyFilters}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
Aplicar filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Preset Modal */}
|
||||
{isPresetModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Guardar filtro</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre del filtro *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={newPresetName}
|
||||
onChange={(e) => setNewPresetName(e.target.value)}
|
||||
placeholder="Ej: Ventas del último mes"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción (opcional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={newPresetDescription}
|
||||
onChange={(e) => setNewPresetDescription(e.target.value)}
|
||||
placeholder="Descripción del filtro"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsPresetModalOpen(false);
|
||||
setNewPresetName('');
|
||||
setNewPresetDescription('');
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePresetSave}
|
||||
disabled={!newPresetName.trim()}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
@@ -1,651 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Input, Select, Modal } from '../../ui';
|
||||
import { Table } from '../../ui';
|
||||
import type {
|
||||
AnalyticsReport,
|
||||
ReportsTableProps,
|
||||
ExportFormat,
|
||||
ReportType,
|
||||
CustomAction
|
||||
} from './types';
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<ReportType, string> = {
|
||||
sales: 'Ventas',
|
||||
production: 'Producción',
|
||||
inventory: 'Inventario',
|
||||
financial: 'Financiero',
|
||||
customer: 'Clientes',
|
||||
performance: 'Rendimiento',
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
active: 'bg-green-100 text-green-800 border-green-200',
|
||||
inactive: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
archived: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
active: 'Activo',
|
||||
inactive: 'Inactivo',
|
||||
archived: 'Archivado',
|
||||
};
|
||||
|
||||
export const ReportsTable: React.FC<ReportsTableProps> = ({
|
||||
reports = [],
|
||||
loading = false,
|
||||
error,
|
||||
selectedReports = [],
|
||||
onSelectionChange,
|
||||
onReportClick,
|
||||
onEditReport,
|
||||
onDeleteReport,
|
||||
onScheduleReport,
|
||||
onShareReport,
|
||||
onExportReport,
|
||||
filters = [],
|
||||
onFiltersChange,
|
||||
sortable = true,
|
||||
pagination,
|
||||
bulkActions = false,
|
||||
customActions = [],
|
||||
tableConfig,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<ReportType | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'archived'>('all');
|
||||
const [sortField, setSortField] = useState<keyof AnalyticsReport>('updated_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [reportToDelete, setReportToDelete] = useState<string | null>(null);
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||
const [reportToSchedule, setReportToSchedule] = useState<AnalyticsReport | null>(null);
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [reportToShare, setReportToShare] = useState<AnalyticsReport | null>(null);
|
||||
|
||||
const filteredAndSortedReports = useMemo(() => {
|
||||
let filtered = reports.filter(report => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
report.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
report.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
report.category.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesType = typeFilter === 'all' || report.type === typeFilter;
|
||||
const matchesStatus = statusFilter === 'all' || report.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
if (sortable && sortField) {
|
||||
filtered.sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
comparison = aVal.localeCompare(bVal);
|
||||
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
comparison = aVal - bVal;
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [reports, searchTerm, typeFilter, statusFilter, sortField, sortDirection, sortable]);
|
||||
|
||||
const handleSort = (field: keyof AnalyticsReport) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(checked ? filteredAndSortedReports.map(r => r.id) : []);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectReport = (reportId: string, checked: boolean) => {
|
||||
if (onSelectionChange) {
|
||||
if (checked) {
|
||||
onSelectionChange([...selectedReports, reportId]);
|
||||
} else {
|
||||
onSelectionChange(selectedReports.filter(id => id !== reportId));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (reportToDelete && onDeleteReport) {
|
||||
onDeleteReport(reportToDelete);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setReportToDelete(null);
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedReports.length > 0 && onDeleteReport) {
|
||||
selectedReports.forEach(id => onDeleteReport(id));
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderActionButton = (report: AnalyticsReport, action: string) => {
|
||||
switch (action) {
|
||||
case 'view':
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onReportClick?.(report)}
|
||||
title="Ver reporte"
|
||||
>
|
||||
👁️
|
||||
</Button>
|
||||
);
|
||||
case 'edit':
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditReport?.(report)}
|
||||
title="Editar reporte"
|
||||
>
|
||||
✏️
|
||||
</Button>
|
||||
);
|
||||
case 'schedule':
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReportToSchedule(report);
|
||||
setIsScheduleModalOpen(true);
|
||||
}}
|
||||
title="Programar reporte"
|
||||
>
|
||||
📅
|
||||
</Button>
|
||||
);
|
||||
case 'share':
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReportToShare(report);
|
||||
setIsShareModalOpen(true);
|
||||
}}
|
||||
title="Compartir reporte"
|
||||
>
|
||||
📤
|
||||
</Button>
|
||||
);
|
||||
case 'export':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExportReport?.(report, 'pdf')}
|
||||
title="Exportar como PDF"
|
||||
>
|
||||
📄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExportReport?.(report, 'excel')}
|
||||
title="Exportar como Excel"
|
||||
>
|
||||
📊
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
case 'delete':
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReportToDelete(report.id);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
title="Eliminar reporte"
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
key: 'select',
|
||||
title: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReports.length === filteredAndSortedReports.length && filteredAndSortedReports.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
),
|
||||
render: (report: AnalyticsReport) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReports.includes(report.id)}
|
||||
onChange={(e) => handleSelectReport(report.id, e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
),
|
||||
width: 50,
|
||||
visible: bulkActions,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Nombre',
|
||||
sortable: true,
|
||||
render: (report: AnalyticsReport) => (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onReportClick?.(report)}
|
||||
className="font-medium text-blue-600 hover:text-blue-800 text-left"
|
||||
>
|
||||
{report.name}
|
||||
</button>
|
||||
{report.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{report.description}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
title: 'Tipo',
|
||||
sortable: true,
|
||||
render: (report: AnalyticsReport) => (
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
{REPORT_TYPE_LABELS[report.type]}
|
||||
</Badge>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
title: 'Categoría',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Estado',
|
||||
sortable: true,
|
||||
render: (report: AnalyticsReport) => (
|
||||
<Badge className={STATUS_COLORS[report.status]}>
|
||||
{STATUS_LABELS[report.status]}
|
||||
</Badge>
|
||||
),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
key: 'last_run',
|
||||
title: 'Última ejecución',
|
||||
sortable: true,
|
||||
render: (report: AnalyticsReport) => (
|
||||
report.last_run
|
||||
? new Date(report.last_run).toLocaleDateString('es-ES')
|
||||
: '-'
|
||||
),
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
key: 'next_run',
|
||||
title: 'Próxima ejecución',
|
||||
sortable: true,
|
||||
render: (report: AnalyticsReport) => (
|
||||
report.next_run
|
||||
? new Date(report.next_run).toLocaleDateString('es-ES')
|
||||
: '-'
|
||||
),
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
title: 'Actualizado',
|
||||
sortable: true,
|
||||
render: (report: AnalyticsReport) => (
|
||||
new Date(report.updated_at).toLocaleDateString('es-ES')
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: 'Acciones',
|
||||
render: (report: AnalyticsReport) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{renderActionButton(report, 'view')}
|
||||
{renderActionButton(report, 'edit')}
|
||||
{renderActionButton(report, 'schedule')}
|
||||
{renderActionButton(report, 'export')}
|
||||
{renderActionButton(report, 'delete')}
|
||||
</div>
|
||||
),
|
||||
width: 200,
|
||||
},
|
||||
].filter(col => col.visible !== false);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 font-medium">Error al cargar los reportes</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="overflow-hidden">
|
||||
{/* Filters and Actions */}
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search */}
|
||||
{(tableConfig?.showSearch !== false) && (
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar reportes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as ReportType | 'all')}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="all">Todos los tipos</option>
|
||||
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
className="w-32"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="active">Activos</option>
|
||||
<option value="inactive">Inactivos</option>
|
||||
<option value="archived">Archivados</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{bulkActions && selectedReports.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
Eliminar seleccionados ({selectedReports.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
data={filteredAndSortedReports}
|
||||
loading={loading}
|
||||
sortable={sortable}
|
||||
onSort={handleSort}
|
||||
currentSort={{ field: sortField as string, direction: sortDirection }}
|
||||
emptyMessage="No se encontraron reportes"
|
||||
className="min-w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-700">
|
||||
Mostrando {Math.min(pagination.pageSize * (pagination.current - 1) + 1, pagination.total)} - {Math.min(pagination.pageSize * pagination.current, pagination.total)} de {pagination.total} reportes
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onChange(pagination.current - 1, pagination.pageSize)}
|
||||
disabled={pagination.current <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium">
|
||||
Página {pagination.current} de {Math.ceil(pagination.total / pagination.pageSize)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onChange(pagination.current + 1, pagination.pageSize)}
|
||||
disabled={pagination.current >= Math.ceil(pagination.total / pagination.pageSize)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setReportToDelete(null);
|
||||
}}
|
||||
title="Confirmar eliminación"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>¿Estás seguro de que deseas eliminar este reporte? Esta acción no se puede deshacer.</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setReportToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Schedule Modal */}
|
||||
<Modal
|
||||
isOpen={isScheduleModalOpen}
|
||||
onClose={() => {
|
||||
setIsScheduleModalOpen(false);
|
||||
setReportToSchedule(null);
|
||||
}}
|
||||
title="Programar reporte"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>Configurar programación para: <strong>{reportToSchedule?.name}</strong></p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Frecuencia
|
||||
</label>
|
||||
<Select>
|
||||
<option value="daily">Diario</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="monthly">Mensual</option>
|
||||
<option value="quarterly">Trimestral</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de ejecución
|
||||
</label>
|
||||
<Input type="time" defaultValue="09:00" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Destinatarios (emails separados por coma)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ejemplo@empresa.com, otro@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsScheduleModalOpen(false);
|
||||
setReportToSchedule(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (reportToSchedule && onScheduleReport) {
|
||||
onScheduleReport(reportToSchedule);
|
||||
}
|
||||
setIsScheduleModalOpen(false);
|
||||
setReportToSchedule(null);
|
||||
}}
|
||||
>
|
||||
Programar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Share Modal */}
|
||||
<Modal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => {
|
||||
setIsShareModalOpen(false);
|
||||
setReportToShare(null);
|
||||
}}
|
||||
title="Compartir reporte"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>Compartir: <strong>{reportToShare?.name}</strong></p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Generar enlace de acceso
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={`${window.location.origin}/reports/${reportToShare?.id}/shared`}
|
||||
readOnly
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/reports/${reportToShare?.id}/shared`);
|
||||
}}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="expire-link" />
|
||||
<label htmlFor="expire-link" className="text-sm">
|
||||
El enlace expira en 7 días
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="password-protect" />
|
||||
<label htmlFor="password-protect" className="text-sm">
|
||||
Proteger con contraseña
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsShareModalOpen(false);
|
||||
setReportToShare(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (reportToShare && onShareReport) {
|
||||
onShareReport(reportToShare);
|
||||
}
|
||||
setIsShareModalOpen(false);
|
||||
setReportToShare(null);
|
||||
}}
|
||||
>
|
||||
Generar enlace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsTable;
|
||||
Reference in New Issue
Block a user