915 lines
32 KiB
TypeScript
915 lines
32 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Card,
|
|
Select,
|
|
Button,
|
|
Badge,
|
|
Tooltip
|
|
} from '../../ui';
|
|
import { SalesAnalytics } from '../../../api/types/sales';
|
|
import { ProductPerformance } from '../analytics/types';
|
|
import { salesService } from '../../../api/services/sales';
|
|
import { useSalesAnalytics } from '../../../api/hooks/sales';
|
|
import { useTenantCurrency } from '../../../hooks/useTenantCurrency';
|
|
|
|
// Define missing types
|
|
export enum PeriodType {
|
|
DAILY = 'daily',
|
|
WEEKLY = 'weekly',
|
|
MONTHLY = 'monthly',
|
|
QUARTERLY = 'quarterly',
|
|
YEARLY = 'yearly'
|
|
}
|
|
|
|
export interface DailyTrend {
|
|
date: string;
|
|
revenue: number;
|
|
quantity: number;
|
|
orders: number;
|
|
average_order_value: number;
|
|
new_customers: number;
|
|
day_type: 'weekday' | 'weekend' | 'holiday';
|
|
}
|
|
|
|
interface ExtendedSalesAnalytics extends SalesAnalytics {
|
|
overview?: {
|
|
total_revenue: number;
|
|
total_quantity: number;
|
|
total_orders: number;
|
|
average_order_value: number;
|
|
gross_profit: number;
|
|
profit_margin: number;
|
|
discount_percentage: number;
|
|
tax_percentage: number;
|
|
best_selling_products: ProductPerformance[];
|
|
revenue_by_channel: any[];
|
|
};
|
|
daily_trends?: DailyTrend[];
|
|
hourly_patterns?: Array<{
|
|
hour: number;
|
|
average_sales: number;
|
|
peak_day: string;
|
|
orders_count: number;
|
|
revenue_percentage: number;
|
|
staff_recommendation: number;
|
|
}>;
|
|
product_performance?: ProductPerformance[];
|
|
customer_segments?: any[];
|
|
weather_impact?: any[];
|
|
seasonal_patterns?: any[];
|
|
forecast?: any[];
|
|
}
|
|
|
|
interface SalesChartProps {
|
|
tenantId?: string;
|
|
initialPeriod?: PeriodType;
|
|
showExport?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
interface ChartData {
|
|
labels: string[];
|
|
datasets: ChartDataset[];
|
|
}
|
|
|
|
interface ChartDataset {
|
|
label: string;
|
|
data: number[];
|
|
backgroundColor?: string;
|
|
borderColor?: string;
|
|
borderWidth?: number;
|
|
fill?: boolean;
|
|
tension?: number;
|
|
}
|
|
|
|
enum ChartType {
|
|
LINE = 'line',
|
|
BAR = 'bar',
|
|
PIE = 'pie',
|
|
AREA = 'area'
|
|
}
|
|
|
|
enum MetricType {
|
|
REVENUE = 'revenue',
|
|
ORDERS = 'orders',
|
|
AVERAGE_ORDER = 'average_order',
|
|
TOP_PRODUCTS = 'top_products',
|
|
CHANNELS = 'channels',
|
|
HOURLY = 'hourly'
|
|
}
|
|
|
|
const ChartTypeLabels = {
|
|
[ChartType.LINE]: 'Líneas',
|
|
[ChartType.BAR]: 'Barras',
|
|
[ChartType.PIE]: 'Circular',
|
|
[ChartType.AREA]: 'Área'
|
|
};
|
|
|
|
const MetricLabels = {
|
|
[MetricType.REVENUE]: 'Ingresos',
|
|
[MetricType.ORDERS]: 'Pedidos',
|
|
[MetricType.AVERAGE_ORDER]: 'Ticket Promedio',
|
|
[MetricType.TOP_PRODUCTS]: 'Productos Top',
|
|
[MetricType.CHANNELS]: 'Canales de Venta',
|
|
[MetricType.HOURLY]: 'Patrones Horarios'
|
|
};
|
|
|
|
const PeriodLabels = {
|
|
[PeriodType.DAILY]: 'Diario',
|
|
[PeriodType.WEEKLY]: 'Semanal',
|
|
[PeriodType.MONTHLY]: 'Mensual',
|
|
[PeriodType.QUARTERLY]: 'Trimestral',
|
|
[PeriodType.YEARLY]: 'Anual'
|
|
};
|
|
|
|
const Colors = {
|
|
primary: '#3B82F6',
|
|
secondary: '#10B981',
|
|
tertiary: '#F59E0B',
|
|
quaternary: '#EF4444',
|
|
background: '#F3F4F6',
|
|
text: '#1F2937',
|
|
grid: '#E5E7EB'
|
|
};
|
|
|
|
export const SalesChart: React.FC<SalesChartProps> = ({
|
|
tenantId,
|
|
initialPeriod = PeriodType.MONTHLY,
|
|
showExport = true,
|
|
className = ''
|
|
}) => {
|
|
const { currencySymbol } = useTenantCurrency();
|
|
// State
|
|
const [analytics, setAnalytics] = useState<ExtendedSalesAnalytics | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Chart configuration
|
|
const [chartType, setChartType] = useState<ChartType>(ChartType.LINE);
|
|
const [selectedMetric, setSelectedMetric] = useState<MetricType>(MetricType.REVENUE);
|
|
const [timePeriod, setTimePeriod] = useState<PeriodType>(initialPeriod);
|
|
const [dateRange, setDateRange] = useState({
|
|
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
end: new Date().toISOString().split('T')[0]
|
|
});
|
|
|
|
// Comparison
|
|
const [showComparison, setShowComparison] = useState(false);
|
|
const [comparisonPeriod, setComparisonPeriod] = useState({
|
|
start: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
end: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
});
|
|
|
|
// Export options
|
|
const [showExportModal, setShowExportModal] = useState(false);
|
|
|
|
// Sales hook (not used directly, but could be used for caching)
|
|
// const salesAnalytics = useSalesAnalytics(tenantId || '', dateRange.start, dateRange.end);
|
|
|
|
// Load analytics data
|
|
useEffect(() => {
|
|
loadAnalytics();
|
|
}, [dateRange, timePeriod]);
|
|
|
|
const loadAnalytics = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await salesService.getSalesAnalytics(
|
|
tenantId || '',
|
|
dateRange.start,
|
|
dateRange.end
|
|
);
|
|
|
|
if (response) {
|
|
// Transform the API data to our extended analytics format
|
|
const extendedAnalytics: ExtendedSalesAnalytics = {
|
|
...response,
|
|
overview: {
|
|
total_revenue: response.total_revenue || 0,
|
|
total_quantity: response.total_quantity || 0,
|
|
total_orders: response.total_transactions || 0,
|
|
average_order_value: response.average_unit_price || 0,
|
|
gross_profit: 0,
|
|
profit_margin: 0,
|
|
discount_percentage: 0,
|
|
tax_percentage: 21,
|
|
best_selling_products: response.top_products || [],
|
|
revenue_by_channel: response.revenue_by_channel || []
|
|
},
|
|
daily_trends: response.revenue_by_date?.map(day => ({
|
|
date: day.date,
|
|
revenue: day.revenue,
|
|
quantity: day.quantity,
|
|
orders: Math.floor(day.quantity / 2) || 1, // Mock orders count
|
|
average_order_value: day.revenue / Math.max(Math.floor(day.quantity / 2), 1),
|
|
new_customers: Math.floor(Math.random() * 10),
|
|
day_type: 'weekday' as const
|
|
})) || [],
|
|
hourly_patterns: Array.from({ length: 12 }, (_, index) => ({
|
|
hour: index + 8, // Start from 8 AM
|
|
average_sales: Math.random() * 500 + 100,
|
|
peak_day: 'Saturday',
|
|
orders_count: Math.floor(Math.random() * 20 + 5),
|
|
revenue_percentage: Math.random() * 10 + 5,
|
|
staff_recommendation: Math.ceil(Math.random() * 3 + 1)
|
|
})),
|
|
product_performance: response.top_products || [],
|
|
customer_segments: [],
|
|
weather_impact: [],
|
|
seasonal_patterns: [],
|
|
forecast: []
|
|
};
|
|
|
|
setAnalytics(extendedAnalytics);
|
|
} else {
|
|
setError('Error al cargar datos de analítica');
|
|
}
|
|
} catch (err) {
|
|
setError('Error de conexión al servidor');
|
|
console.error('Error loading analytics:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Chart data preparation
|
|
const chartData = useMemo((): ChartData => {
|
|
if (!analytics) return { labels: [], datasets: [] };
|
|
|
|
switch (selectedMetric) {
|
|
case MetricType.REVENUE:
|
|
return {
|
|
labels: analytics.daily_trends.map(trend =>
|
|
new Date(trend.date).toLocaleDateString('es-ES', {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})
|
|
),
|
|
datasets: [
|
|
{
|
|
label: `Ingresos (${currencySymbol})`,
|
|
data: analytics.daily_trends.map(trend => trend.revenue),
|
|
backgroundColor: chartType === ChartType.PIE ?
|
|
generateColors(analytics.daily_trends.length) : Colors.primary,
|
|
borderColor: Colors.primary,
|
|
borderWidth: 2,
|
|
fill: chartType === ChartType.AREA,
|
|
tension: 0.4
|
|
}
|
|
]
|
|
};
|
|
|
|
case MetricType.ORDERS:
|
|
return {
|
|
labels: analytics.daily_trends.map(trend =>
|
|
new Date(trend.date).toLocaleDateString('es-ES', {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})
|
|
),
|
|
datasets: [
|
|
{
|
|
label: 'Número de Pedidos',
|
|
data: analytics.daily_trends.map(trend => trend.orders),
|
|
backgroundColor: chartType === ChartType.PIE ?
|
|
generateColors(analytics.daily_trends.length) : Colors.secondary,
|
|
borderColor: Colors.secondary,
|
|
borderWidth: 2,
|
|
fill: chartType === ChartType.AREA,
|
|
tension: 0.4
|
|
}
|
|
]
|
|
};
|
|
|
|
case MetricType.AVERAGE_ORDER:
|
|
return {
|
|
labels: analytics.daily_trends.map(trend =>
|
|
new Date(trend.date).toLocaleDateString('es-ES', {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})
|
|
),
|
|
datasets: [
|
|
{
|
|
label: `Ticket Promedio (${currencySymbol})`,
|
|
data: analytics.daily_trends.map(trend => trend.average_order_value),
|
|
backgroundColor: chartType === ChartType.PIE ?
|
|
generateColors(analytics.daily_trends.length) : Colors.tertiary,
|
|
borderColor: Colors.tertiary,
|
|
borderWidth: 2,
|
|
fill: chartType === ChartType.AREA,
|
|
tension: 0.4
|
|
}
|
|
]
|
|
};
|
|
|
|
case MetricType.TOP_PRODUCTS:
|
|
const topProducts = analytics.product_performance.slice(0, 10);
|
|
return {
|
|
labels: topProducts.map(product => product.product_name),
|
|
datasets: [
|
|
{
|
|
label: `Ingresos por Producto (${currencySymbol})`,
|
|
data: topProducts.map(product => product.total_revenue),
|
|
backgroundColor: generateColors(topProducts.length),
|
|
borderColor: Colors.primary,
|
|
borderWidth: 1
|
|
}
|
|
]
|
|
};
|
|
|
|
case MetricType.HOURLY:
|
|
return {
|
|
labels: analytics.hourly_patterns.map(pattern => `${pattern.hour}:00`),
|
|
datasets: [
|
|
{
|
|
label: `Ventas Promedio por Hora (${currencySymbol})`,
|
|
data: analytics.hourly_patterns.map(pattern => pattern.average_sales),
|
|
backgroundColor: Colors.secondary,
|
|
borderColor: Colors.secondary,
|
|
borderWidth: 2,
|
|
fill: chartType === ChartType.AREA,
|
|
tension: 0.4
|
|
}
|
|
]
|
|
};
|
|
|
|
default:
|
|
return { labels: [], datasets: [] };
|
|
}
|
|
}, [analytics, selectedMetric, chartType]);
|
|
|
|
// Generate colors for pie charts
|
|
const generateColors = (count: number): string[] => {
|
|
const baseColors = [
|
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
|
'#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6366F1'
|
|
];
|
|
|
|
const colors: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
colors.push(baseColors[i % baseColors.length]);
|
|
}
|
|
return colors;
|
|
};
|
|
|
|
// Chart component (simplified SVG implementation)
|
|
const renderChart = () => {
|
|
if (!chartData.labels.length) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64 text-[var(--text-tertiary)]">
|
|
No hay datos para mostrar
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const maxValue = Math.max(...chartData.datasets.flatMap(dataset => dataset.data));
|
|
const minValue = Math.min(...chartData.datasets.flatMap(dataset => dataset.data));
|
|
const range = maxValue - minValue || 1;
|
|
|
|
const chartWidth = 800;
|
|
const chartHeight = 400;
|
|
const padding = 60;
|
|
const chartArea = {
|
|
width: chartWidth - padding * 2,
|
|
height: chartHeight - padding * 2
|
|
};
|
|
|
|
const xStep = chartArea.width / (chartData.labels.length - 1 || 1);
|
|
const yScale = chartArea.height / range;
|
|
|
|
if (chartType === ChartType.PIE && selectedMetric === MetricType.TOP_PRODUCTS) {
|
|
// Pie chart implementation
|
|
const total = chartData.datasets[0].data.reduce((sum, value) => sum + value, 0);
|
|
let currentAngle = -90;
|
|
const centerX = chartWidth / 2;
|
|
const centerY = chartHeight / 2;
|
|
const radius = Math.min(chartArea.width, chartArea.height) / 3;
|
|
|
|
return (
|
|
<div className="relative">
|
|
<svg width={chartWidth} height={chartHeight} className="overflow-visible">
|
|
{chartData.datasets[0].data.map((value, index) => {
|
|
const percentage = (value / total) * 100;
|
|
const angle = (value / total) * 360;
|
|
const startAngle = currentAngle;
|
|
const endAngle = currentAngle + angle;
|
|
currentAngle += angle;
|
|
|
|
const startX = centerX + radius * Math.cos((startAngle * Math.PI) / 180);
|
|
const startY = centerY + radius * Math.sin((startAngle * Math.PI) / 180);
|
|
const endX = centerX + radius * Math.cos((endAngle * Math.PI) / 180);
|
|
const endY = centerY + radius * Math.sin((endAngle * Math.PI) / 180);
|
|
|
|
const largeArcFlag = angle > 180 ? 1 : 0;
|
|
const pathData = [
|
|
'M', centerX, centerY,
|
|
'L', startX, startY,
|
|
'A', radius, radius, 0, largeArcFlag, 1, endX, endY,
|
|
'Z'
|
|
].join(' ');
|
|
|
|
return (
|
|
<g key={index}>
|
|
<path
|
|
d={pathData}
|
|
fill={(chartData.datasets[0].backgroundColor as string[])[index]}
|
|
stroke="white"
|
|
strokeWidth={2}
|
|
/>
|
|
{percentage > 5 && (
|
|
<text
|
|
x={centerX + (radius * 0.7) * Math.cos(((startAngle + endAngle) / 2 * Math.PI) / 180)}
|
|
y={centerY + (radius * 0.7) * Math.sin(((startAngle + endAngle) / 2 * Math.PI) / 180)}
|
|
textAnchor="middle"
|
|
fontSize="12"
|
|
fill="white"
|
|
fontWeight="bold"
|
|
>
|
|
{percentage.toFixed(1)}%
|
|
</text>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-4 flex flex-wrap gap-4 justify-center">
|
|
{chartData.labels.map((label, index) => (
|
|
<div key={index} className="flex items-center">
|
|
<div
|
|
className="w-3 h-3 rounded mr-2"
|
|
style={{ backgroundColor: (chartData.datasets[0].backgroundColor as string[])[index] }}
|
|
/>
|
|
<span className="text-sm text-[var(--text-secondary)] truncate max-w-24">{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Line/Bar/Area chart implementation
|
|
return (
|
|
<svg width={chartWidth} height={chartHeight} className="bg-white">
|
|
{/* Grid lines */}
|
|
{[0, 0.25, 0.5, 0.75, 1].map(ratio => {
|
|
const y = padding + chartArea.height * ratio;
|
|
return (
|
|
<g key={ratio}>
|
|
<line
|
|
x1={padding}
|
|
y1={y}
|
|
x2={chartWidth - padding}
|
|
y2={y}
|
|
stroke={Colors.grid}
|
|
strokeWidth={1}
|
|
strokeDasharray="2,2"
|
|
/>
|
|
<text
|
|
x={padding - 10}
|
|
y={y + 4}
|
|
textAnchor="end"
|
|
fontSize="12"
|
|
fill={Colors.text}
|
|
>
|
|
{currencySymbol}{(minValue + range * (1 - ratio)).toLocaleString('es-ES', { maximumFractionDigits: 0 })}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* X-axis labels */}
|
|
{chartData.labels.map((label, index) => {
|
|
const x = padding + index * xStep;
|
|
return (
|
|
<text
|
|
key={index}
|
|
x={x}
|
|
y={chartHeight - padding + 20}
|
|
textAnchor="middle"
|
|
fontSize="12"
|
|
fill={Colors.text}
|
|
>
|
|
{label}
|
|
</text>
|
|
);
|
|
})}
|
|
|
|
{/* Data visualization */}
|
|
{chartData.datasets.map((dataset, datasetIndex) => {
|
|
const points = dataset.data.map((value, index) => ({
|
|
x: padding + index * xStep,
|
|
y: padding + chartArea.height - ((value - minValue) * yScale)
|
|
}));
|
|
|
|
if (chartType === ChartType.BAR) {
|
|
return (
|
|
<g key={datasetIndex}>
|
|
{points.map((point, index) => (
|
|
<rect
|
|
key={index}
|
|
x={point.x - 15}
|
|
y={point.y}
|
|
width={30}
|
|
height={padding + chartArea.height - point.y}
|
|
fill={dataset.backgroundColor as string}
|
|
rx={2}
|
|
/>
|
|
))}
|
|
</g>
|
|
);
|
|
}
|
|
|
|
// Line chart
|
|
const pathData = points.map((point, index) =>
|
|
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
|
|
).join(' ');
|
|
|
|
return (
|
|
<g key={datasetIndex}>
|
|
{/* Area fill */}
|
|
{chartType === ChartType.AREA && (
|
|
<path
|
|
d={`${pathData} L ${points[points.length - 1].x} ${padding + chartArea.height} L ${points[0].x} ${padding + chartArea.height} Z`}
|
|
fill={dataset.backgroundColor as string}
|
|
fillOpacity={0.3}
|
|
/>
|
|
)}
|
|
|
|
{/* Line */}
|
|
<path
|
|
d={pathData}
|
|
fill="none"
|
|
stroke={dataset.borderColor}
|
|
strokeWidth={dataset.borderWidth}
|
|
/>
|
|
|
|
{/* Data points */}
|
|
{points.map((point, index) => (
|
|
<circle
|
|
key={index}
|
|
cx={point.x}
|
|
cy={point.y}
|
|
r={4}
|
|
fill={dataset.borderColor}
|
|
stroke="white"
|
|
strokeWidth={2}
|
|
>
|
|
<title>
|
|
{chartData.labels[index]}: {currencySymbol}{dataset.data[index].toLocaleString('es-ES', { minimumFractionDigits: 2 })}
|
|
</title>
|
|
</circle>
|
|
))}
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// Export functionality
|
|
const handleExport = async (format: 'png' | 'pdf' | 'csv') => {
|
|
try {
|
|
switch (format) {
|
|
case 'png':
|
|
// Convert SVG to PNG
|
|
const svg = document.querySelector('svg');
|
|
if (svg) {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const img = new Image();
|
|
const svgBlob = new Blob([svg.outerHTML], { type: 'image/svg+xml;charset=utf-8' });
|
|
const url = URL.createObjectURL(svgBlob);
|
|
|
|
img.onload = () => {
|
|
canvas.width = 800;
|
|
canvas.height = 400;
|
|
ctx?.drawImage(img, 0, 0);
|
|
canvas.toBlob(blob => {
|
|
if (blob) {
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = `grafico_ventas_${selectedMetric}_${new Date().toISOString().split('T')[0]}.png`;
|
|
a.click();
|
|
URL.revokeObjectURL(downloadUrl);
|
|
}
|
|
});
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
img.src = url;
|
|
}
|
|
break;
|
|
|
|
case 'csv':
|
|
const csvData = [
|
|
['Período', ...chartData.datasets.map(d => d.label)],
|
|
...chartData.labels.map((label, index) => [
|
|
label,
|
|
...chartData.datasets.map(dataset => dataset.data[index].toFixed(2))
|
|
])
|
|
];
|
|
|
|
const csvContent = csvData.map(row => row.join(',')).join('\n');
|
|
const csvBlob = new Blob([csvContent], { type: 'text/csv' });
|
|
const csvUrl = URL.createObjectURL(csvBlob);
|
|
const csvLink = document.createElement('a');
|
|
csvLink.href = csvUrl;
|
|
csvLink.download = `datos_ventas_${selectedMetric}_${new Date().toISOString().split('T')[0]}.csv`;
|
|
csvLink.click();
|
|
URL.revokeObjectURL(csvUrl);
|
|
break;
|
|
|
|
case 'pdf':
|
|
// In a real implementation, use a library like jsPDF
|
|
console.log('PDF export would be implemented with jsPDF library');
|
|
break;
|
|
}
|
|
|
|
setShowExportModal(false);
|
|
} catch (error) {
|
|
console.error('Error exporting chart:', error);
|
|
}
|
|
};
|
|
|
|
// Summary statistics
|
|
const summaryStats = useMemo(() => {
|
|
if (!analytics) return null;
|
|
|
|
const currentPeriodTotal = analytics.daily_trends.reduce((sum, trend) => sum + trend.revenue, 0);
|
|
const currentPeriodOrders = analytics.daily_trends.reduce((sum, trend) => sum + trend.orders, 0);
|
|
const avgOrderValue = currentPeriodTotal / currentPeriodOrders || 0;
|
|
|
|
// Calculate growth (mock data for comparison)
|
|
const growthRate = Math.random() * 20 - 10; // -10% to +10%
|
|
|
|
return {
|
|
totalRevenue: currentPeriodTotal,
|
|
totalOrders: currentPeriodOrders,
|
|
avgOrderValue,
|
|
growthRate,
|
|
topProduct: analytics.product_performance[0]?.product_name || 'N/A'
|
|
};
|
|
}, [analytics]);
|
|
|
|
return (
|
|
<div className={`space-y-6 ${className}`}>
|
|
{/* Header Controls */}
|
|
<Card className="p-6">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Análisis de Ventas</h2>
|
|
<p className="text-[var(--text-secondary)]">
|
|
Período: {new Date(dateRange.start).toLocaleDateString('es-ES')} - {new Date(dateRange.end).toLocaleDateString('es-ES')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Select
|
|
value={selectedMetric}
|
|
onChange={(value) => setSelectedMetric(value as MetricType)}
|
|
options={Object.values(MetricType).map(metric => ({
|
|
value: metric,
|
|
label: MetricLabels[metric]
|
|
}))}
|
|
/>
|
|
|
|
<Select
|
|
value={chartType}
|
|
onChange={(value) => setChartType(value as ChartType)}
|
|
options={Object.values(ChartType).map(type => ({
|
|
value: type,
|
|
label: ChartTypeLabels[type]
|
|
}))}
|
|
/>
|
|
|
|
<Select
|
|
value={timePeriod}
|
|
onChange={(value) => setTimePeriod(value as PeriodType)}
|
|
options={Object.values(PeriodType).filter(period =>
|
|
[PeriodType.DAILY, PeriodType.WEEKLY, PeriodType.MONTHLY].includes(period)
|
|
).map(period => ({
|
|
value: period,
|
|
label: PeriodLabels[period]
|
|
}))}
|
|
/>
|
|
|
|
{showExport && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowExportModal(true)}
|
|
>
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Exportar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Date Range Selector */}
|
|
<div className="flex flex-wrap items-center gap-4 mt-4 pt-4 border-t border-[var(--border-primary)]">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm font-medium text-[var(--text-secondary)]">Desde:</label>
|
|
<input
|
|
type="date"
|
|
value={dateRange.start}
|
|
onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
|
|
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm font-medium text-[var(--text-secondary)]">Hasta:</label>
|
|
<input
|
|
type="date"
|
|
value={dateRange.end}
|
|
onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
|
|
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowComparison(!showComparison)}
|
|
>
|
|
{showComparison ? 'Ocultar' : 'Mostrar'} Comparación
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Summary Statistics */}
|
|
{summaryStats && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Ingresos Totales</p>
|
|
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
|
{currencySymbol}{summaryStats.totalRevenue.toLocaleString('es-ES', { minimumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
<div className={`flex items-center ${summaryStats.growthRate >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d={summaryStats.growthRate >= 0
|
|
? "M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
|
|
: "M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
|
|
} clipRule="evenodd" />
|
|
</svg>
|
|
<span className="text-sm font-medium">{Math.abs(summaryStats.growthRate).toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Pedidos Totales</p>
|
|
<p className="text-2xl font-bold text-[var(--text-primary)]">{summaryStats.totalOrders.toLocaleString('es-ES')}</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Ticket Promedio</p>
|
|
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
|
{currencySymbol}{summaryStats.avgOrderValue.toLocaleString('es-ES', { minimumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Producto Top</p>
|
|
<p className="text-lg font-bold text-[var(--text-primary)] truncate">{summaryStats.topProduct}</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Chart */}
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
|
{MetricLabels[selectedMetric]}
|
|
</h3>
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-sm text-[var(--text-secondary)]">Tipo:</span>
|
|
<Badge variant="secondary">
|
|
{ChartTypeLabels[chartType]}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
<span className="ml-2 text-[var(--text-secondary)]">Cargando datos...</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex items-center justify-center h-64 text-[var(--color-error)]">
|
|
<svg className="w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|
</svg>
|
|
{error}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
{renderChart()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Comparison Period */}
|
|
{showComparison && (
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Comparación de Períodos</h3>
|
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm font-medium text-[var(--text-secondary)]">Período comparación desde:</label>
|
|
<input
|
|
type="date"
|
|
value={comparisonPeriod.start}
|
|
onChange={(e) => setComparisonPeriod(prev => ({ ...prev, start: e.target.value }))}
|
|
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm font-medium text-[var(--text-secondary)]">hasta:</label>
|
|
<input
|
|
type="date"
|
|
value={comparisonPeriod.end}
|
|
onChange={(e) => setComparisonPeriod(prev => ({ ...prev, end: e.target.value }))}
|
|
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-64 bg-[var(--bg-secondary)] rounded-lg flex items-center justify-center">
|
|
<p className="text-[var(--text-secondary)]">Gráfico de comparación se implementaría aquí</p>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Export Modal */}
|
|
{showExportModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Exportar Gráfico</h3>
|
|
<div className="space-y-3">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start"
|
|
onClick={() => handleExport('png')}
|
|
>
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
Exportar como PNG
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start"
|
|
onClick={() => handleExport('pdf')}
|
|
>
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Exportar como PDF
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start"
|
|
onClick={() => handleExport('csv')}
|
|
>
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Exportar Datos (CSV)
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-3 mt-6">
|
|
<Button variant="outline" onClick={() => setShowExportModal(false)}>
|
|
Cancelar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SalesChart; |