Clean frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 18:35:29 +02:00
parent 68bb5a6449
commit 2bbbf33d7b
27 changed files with 0 additions and 14880 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;