Files
bakery-ia/frontend/src/components/domain/analytics/AnalyticsDashboard.tsx

617 lines
22 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
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>
2025-09-17 16:06:30 +02:00
<p className="text-sm text-[var(--text-secondary)]">Velocidad de Venta</p>
2025-08-28 10:41:04 +02:00
</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;