ADD new frontend
This commit is contained in:
617
frontend/src/components/domain/analytics/AnalyticsDashboard.tsx
Normal file
617
frontend/src/components/domain/analytics/AnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
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-[var(--color-info)]') => (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{title}</p>
|
||||
<p className={`text-2xl font-bold ${colorClass}`}>{value}</p>
|
||||
{subtitle && <p className="text-sm text-[var(--text-tertiary)]">{subtitle}</p>}
|
||||
{trend !== undefined && (
|
||||
<div className="flex items-center mt-1">
|
||||
<span className={`text-xs ${trend >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{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-[var(--color-success)]'
|
||||
)}
|
||||
{renderKPICard(
|
||||
'Pedidos',
|
||||
bakeryMetrics.sales.total_orders.toLocaleString(),
|
||||
`Ticket medio: €${bakeryMetrics.sales.average_order_value.toFixed(2)}`,
|
||||
bakeryMetrics.sales.order_growth,
|
||||
'📦',
|
||||
'text-[var(--color-info)]'
|
||||
)}
|
||||
{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-[var(--color-primary)]'
|
||||
)}
|
||||
</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-[var(--bg-secondary)] rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{channel.channel}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{channel.orders} pedidos • {channel.customers} clientes
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-[var(--color-success)]">
|
||||
€{channel.revenue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
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-[var(--bg-secondary)] rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{product.product_name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{product.category} • {product.quantity_sold} vendidos
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-[var(--color-success)]">
|
||||
€{product.revenue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
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-[var(--color-info)]">
|
||||
{bakeryMetrics.operational.staff_productivity.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Productividad Personal</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{bakeryMetrics.operational.equipment_uptime.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Tiempo Activo Equipos</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{bakeryMetrics.production.waste_percentage.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">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-[var(--text-secondary)]">Seguridad Alimentaria</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-error)]">
|
||||
{bakeryMetrics.inventory.stockout_rate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">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-[var(--text-secondary)]">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-[var(--text-secondary)]">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-[var(--text-secondary)]">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-[var(--text-tertiary)]">Vista en desarrollo: {activeView}</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)] 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-[var(--text-primary)]">Analytics Dashboard</h1>
|
||||
<p className="text-[var(--text-secondary)]">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-[var(--border-primary)]">
|
||||
<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-[var(--color-info)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<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;
|
||||
Reference in New Issue
Block a user