Clean frontend
This commit is contained in:
@@ -1,617 +0,0 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { Card, Button, Badge, Select, DatePicker } from '../../ui';
|
|
||||||
import { ChartWidget } from './ChartWidget';
|
|
||||||
import { ReportsTable } from './ReportsTable';
|
|
||||||
import { FilterPanel } from './FilterPanel';
|
|
||||||
import { ExportOptions } from './ExportOptions';
|
|
||||||
import type {
|
|
||||||
BakeryMetrics,
|
|
||||||
AnalyticsReport,
|
|
||||||
ChartWidget as ChartWidgetType,
|
|
||||||
FilterPanel as FilterPanelType,
|
|
||||||
AppliedFilter,
|
|
||||||
TimeRange,
|
|
||||||
ExportFormat
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
interface AnalyticsDashboardProps {
|
|
||||||
className?: string;
|
|
||||||
initialTimeRange?: TimeRange;
|
|
||||||
showFilters?: boolean;
|
|
||||||
showExport?: boolean;
|
|
||||||
customCharts?: ChartWidgetType[];
|
|
||||||
onMetricsLoad?: (metrics: BakeryMetrics) => void;
|
|
||||||
onExport?: (format: ExportFormat, options: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
||||||
{ value: 'today', label: 'Hoy' },
|
|
||||||
{ value: 'yesterday', label: 'Ayer' },
|
|
||||||
{ value: 'thisWeek', label: 'Esta semana' },
|
|
||||||
{ value: 'lastWeek', label: 'Semana pasada' },
|
|
||||||
{ value: 'thisMonth', label: 'Este mes' },
|
|
||||||
{ value: 'lastMonth', label: 'Mes pasado' },
|
|
||||||
{ value: 'thisQuarter', label: 'Este trimestre' },
|
|
||||||
{ value: 'thisYear', label: 'Este año' },
|
|
||||||
{ value: 'custom', label: 'Personalizado' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const AnalyticsDashboard: React.FC<AnalyticsDashboardProps> = ({
|
|
||||||
className = '',
|
|
||||||
initialTimeRange = 'thisMonth',
|
|
||||||
showFilters = true,
|
|
||||||
showExport = true,
|
|
||||||
customCharts = [],
|
|
||||||
onMetricsLoad,
|
|
||||||
onExport,
|
|
||||||
}) => {
|
|
||||||
const [selectedTimeRange, setSelectedTimeRange] = useState<TimeRange>(initialTimeRange);
|
|
||||||
const [customDateRange, setCustomDateRange] = useState<{ from: Date; to: Date } | null>(null);
|
|
||||||
const [bakeryMetrics, setBakeryMetrics] = useState<BakeryMetrics | null>(null);
|
|
||||||
const [reports, setReports] = useState<AnalyticsReport[]>([]);
|
|
||||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilter[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [activeView, setActiveView] = useState<'overview' | 'sales' | 'production' | 'inventory' | 'financial' | 'reports'>('overview');
|
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
|
||||||
const [selectedMetricCards, setSelectedMetricCards] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const loadAnalyticsData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// Mock data - in real implementation, this would fetch from API
|
|
||||||
const mockMetrics: BakeryMetrics = {
|
|
||||||
sales: {
|
|
||||||
total_revenue: 45250.75,
|
|
||||||
total_orders: 1247,
|
|
||||||
average_order_value: 36.32,
|
|
||||||
revenue_growth: 12.5,
|
|
||||||
order_growth: 8.3,
|
|
||||||
conversion_rate: 67.8,
|
|
||||||
top_products: [
|
|
||||||
{
|
|
||||||
product_id: 'prod-1',
|
|
||||||
product_name: 'Pan de Molde Integral',
|
|
||||||
category: 'Pan',
|
|
||||||
quantity_sold: 342,
|
|
||||||
revenue: 8540.50,
|
|
||||||
profit_margin: 45.2,
|
|
||||||
growth_rate: 15.3,
|
|
||||||
stock_turns: 12.4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
product_id: 'prod-2',
|
|
||||||
product_name: 'Croissant Mantequilla',
|
|
||||||
category: 'Bollería',
|
|
||||||
quantity_sold: 289,
|
|
||||||
revenue: 7225.00,
|
|
||||||
profit_margin: 52.1,
|
|
||||||
growth_rate: 22.7,
|
|
||||||
stock_turns: 8.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
sales_by_channel: [
|
|
||||||
{
|
|
||||||
channel: 'Tienda Física',
|
|
||||||
revenue: 28500.45,
|
|
||||||
orders: 789,
|
|
||||||
customers: 634,
|
|
||||||
conversion_rate: 72.1,
|
|
||||||
average_order_value: 36.12,
|
|
||||||
growth_rate: 8.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
channel: 'Online',
|
|
||||||
revenue: 16750.30,
|
|
||||||
orders: 458,
|
|
||||||
customers: 412,
|
|
||||||
conversion_rate: 61.3,
|
|
||||||
average_order_value: 36.58,
|
|
||||||
growth_rate: 18.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
hourly_sales: [],
|
|
||||||
seasonal_trends: []
|
|
||||||
},
|
|
||||||
production: {
|
|
||||||
total_batches: 156,
|
|
||||||
success_rate: 94.2,
|
|
||||||
waste_percentage: 3.8,
|
|
||||||
production_efficiency: 87.5,
|
|
||||||
quality_score: 92.1,
|
|
||||||
capacity_utilization: 76.3,
|
|
||||||
production_costs: 18750.25,
|
|
||||||
batch_cycle_time: 4.2,
|
|
||||||
top_recipes: [
|
|
||||||
{
|
|
||||||
recipe_id: 'rec-1',
|
|
||||||
recipe_name: 'Pan de Molde Tradicional',
|
|
||||||
batches_produced: 45,
|
|
||||||
success_rate: 96.8,
|
|
||||||
average_yield: 98.2,
|
|
||||||
cost_per_unit: 1.25,
|
|
||||||
quality_score: 94.5,
|
|
||||||
profitability: 65.2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
quality_trends: []
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
total_stock_value: 12350.80,
|
|
||||||
turnover_rate: 8.4,
|
|
||||||
days_of_inventory: 12.5,
|
|
||||||
stockout_rate: 2.1,
|
|
||||||
waste_value: 245.30,
|
|
||||||
carrying_costs: 1850.75,
|
|
||||||
reorder_efficiency: 89.3,
|
|
||||||
supplier_performance: [
|
|
||||||
{
|
|
||||||
supplier_id: 'sup-1',
|
|
||||||
supplier_name: 'Harinas Premium SA',
|
|
||||||
on_time_delivery: 94.2,
|
|
||||||
quality_rating: 4.6,
|
|
||||||
cost_competitiveness: 87.5,
|
|
||||||
total_orders: 24,
|
|
||||||
total_value: 8450.75,
|
|
||||||
reliability_score: 91.8
|
|
||||||
}
|
|
||||||
],
|
|
||||||
stock_levels: [],
|
|
||||||
expiry_alerts: []
|
|
||||||
},
|
|
||||||
financial: {
|
|
||||||
gross_profit: 26500.25,
|
|
||||||
net_profit: 18750.30,
|
|
||||||
profit_margin: 41.4,
|
|
||||||
cost_of_goods_sold: 18750.50,
|
|
||||||
operating_expenses: 7750.95,
|
|
||||||
break_even_point: 28500.00,
|
|
||||||
cash_flow: 15250.80,
|
|
||||||
roi: 23.8,
|
|
||||||
expense_breakdown: [
|
|
||||||
{
|
|
||||||
category: 'Ingredientes',
|
|
||||||
amount: 12500.25,
|
|
||||||
percentage: 66.7,
|
|
||||||
change_from_previous: 5.2,
|
|
||||||
budget_variance: -2.1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Personal',
|
|
||||||
amount: 4250.25,
|
|
||||||
percentage: 22.7,
|
|
||||||
change_from_previous: 8.5,
|
|
||||||
budget_variance: 3.2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Suministros',
|
|
||||||
amount: 2000.00,
|
|
||||||
percentage: 10.6,
|
|
||||||
change_from_previous: -1.5,
|
|
||||||
budget_variance: -0.8
|
|
||||||
}
|
|
||||||
],
|
|
||||||
profit_trends: []
|
|
||||||
},
|
|
||||||
customer: {
|
|
||||||
total_customers: 1524,
|
|
||||||
new_customers: 234,
|
|
||||||
returning_customers: 1290,
|
|
||||||
customer_retention_rate: 84.6,
|
|
||||||
customer_lifetime_value: 285.40,
|
|
||||||
average_purchase_frequency: 2.8,
|
|
||||||
customer_satisfaction: 4.6,
|
|
||||||
customer_segments: [
|
|
||||||
{
|
|
||||||
segment_id: 'seg-1',
|
|
||||||
segment_name: 'Clientes Frecuentes',
|
|
||||||
customer_count: 456,
|
|
||||||
revenue_contribution: 68.2,
|
|
||||||
average_order_value: 42.50,
|
|
||||||
purchase_frequency: 5.2,
|
|
||||||
retention_rate: 92.3,
|
|
||||||
growth_rate: 12.8
|
|
||||||
}
|
|
||||||
],
|
|
||||||
loyalty_program_performance: []
|
|
||||||
},
|
|
||||||
operational: {
|
|
||||||
staff_productivity: 87.5,
|
|
||||||
equipment_uptime: 94.2,
|
|
||||||
energy_consumption: 2450.75,
|
|
||||||
delivery_performance: 91.8,
|
|
||||||
food_safety_score: 98.5,
|
|
||||||
compliance_rate: 96.2,
|
|
||||||
maintenance_costs: 1250.30,
|
|
||||||
operational_efficiency: 89.4
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setBakeryMetrics(mockMetrics);
|
|
||||||
if (onMetricsLoad) {
|
|
||||||
onMetricsLoad(mockMetrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock reports
|
|
||||||
const mockReports: AnalyticsReport[] = [
|
|
||||||
{
|
|
||||||
id: 'rep-1',
|
|
||||||
name: 'Reporte de Ventas Mensual',
|
|
||||||
description: 'Análisis completo de ventas del mes',
|
|
||||||
type: 'sales',
|
|
||||||
category: 'Ventas',
|
|
||||||
status: 'active',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
tenant_id: 'tenant-1',
|
|
||||||
config: {
|
|
||||||
data_source: 'sales_db',
|
|
||||||
refresh_interval: 60,
|
|
||||||
cache_duration: 30,
|
|
||||||
max_records: 10000,
|
|
||||||
default_time_range: 'thisMonth',
|
|
||||||
default_filters: {},
|
|
||||||
visualization_type: 'bar',
|
|
||||||
aggregation_rules: []
|
|
||||||
},
|
|
||||||
metrics: [],
|
|
||||||
filters: [],
|
|
||||||
access_permissions: [],
|
|
||||||
tags: ['ventas', 'mensual']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
setReports(mockReports);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading analytics data:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedTimeRange, customDateRange, appliedFilters, onMetricsLoad]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAnalyticsData();
|
|
||||||
}, [loadAnalyticsData]);
|
|
||||||
|
|
||||||
const handleTimeRangeChange = (newRange: TimeRange) => {
|
|
||||||
setSelectedTimeRange(newRange);
|
|
||||||
if (newRange !== 'custom') {
|
|
||||||
setCustomDateRange(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFiltersChange = (filters: AppliedFilter[]) => {
|
|
||||||
setAppliedFilters(filters);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = (format: ExportFormat, options: any) => {
|
|
||||||
if (onExport) {
|
|
||||||
onExport(format, { ...options, view: activeView, metrics: bakeryMetrics });
|
|
||||||
}
|
|
||||||
setIsExportModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderKPICard = (title: string, value: string | number, subtitle?: string, trend?: number, icon?: string, colorClass: string = 'text-blue-600') => (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">{title}</p>
|
|
||||||
<p className={`text-2xl font-bold ${colorClass}`}>{value}</p>
|
|
||||||
{subtitle && <p className="text-sm text-gray-500">{subtitle}</p>}
|
|
||||||
{trend !== undefined && (
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<span className={`text-xs ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{trend >= 0 ? '↗' : '↘'} {Math.abs(trend).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{icon && <span className="text-2xl">{icon}</span>}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderOverviewDashboard = () => {
|
|
||||||
if (!bakeryMetrics) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* KPI Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{renderKPICard(
|
|
||||||
'Ingresos Totales',
|
|
||||||
`€${bakeryMetrics.sales.total_revenue.toLocaleString()}`,
|
|
||||||
undefined,
|
|
||||||
bakeryMetrics.sales.revenue_growth,
|
|
||||||
'💰',
|
|
||||||
'text-green-600'
|
|
||||||
)}
|
|
||||||
{renderKPICard(
|
|
||||||
'Pedidos',
|
|
||||||
bakeryMetrics.sales.total_orders.toLocaleString(),
|
|
||||||
`Ticket medio: €${bakeryMetrics.sales.average_order_value.toFixed(2)}`,
|
|
||||||
bakeryMetrics.sales.order_growth,
|
|
||||||
'📦',
|
|
||||||
'text-blue-600'
|
|
||||||
)}
|
|
||||||
{renderKPICard(
|
|
||||||
'Margen de Beneficio',
|
|
||||||
`${bakeryMetrics.financial.profit_margin.toFixed(1)}%`,
|
|
||||||
`Beneficio: €${bakeryMetrics.financial.net_profit.toLocaleString()}`,
|
|
||||||
undefined,
|
|
||||||
'📈',
|
|
||||||
'text-purple-600'
|
|
||||||
)}
|
|
||||||
{renderKPICard(
|
|
||||||
'Tasa de Éxito Producción',
|
|
||||||
`${bakeryMetrics.production.success_rate.toFixed(1)}%`,
|
|
||||||
`${bakeryMetrics.production.total_batches} lotes`,
|
|
||||||
undefined,
|
|
||||||
'🏭',
|
|
||||||
'text-orange-600'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Grid */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Ventas por Canal</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{bakeryMetrics.sales.sales_by_channel.map((channel, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{channel.channel}</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{channel.orders} pedidos • {channel.customers} clientes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-semibold text-green-600">
|
|
||||||
€{channel.revenue.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Conv. {channel.conversion_rate.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Productos Top</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{bakeryMetrics.sales.top_products.map((product, index) => (
|
|
||||||
<div key={product.product_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{product.product_name}</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{product.category} • {product.quantity_sold} vendidos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-semibold text-green-600">
|
|
||||||
€{product.revenue.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Margen {product.profit_margin.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Operational Metrics */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Métricas Operacionales</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-blue-600">
|
|
||||||
{bakeryMetrics.operational.staff_productivity.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Productividad Personal</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-green-600">
|
|
||||||
{bakeryMetrics.operational.equipment_uptime.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Tiempo Activo Equipos</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-orange-600">
|
|
||||||
{bakeryMetrics.production.waste_percentage.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Desperdicio</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-purple-600">
|
|
||||||
{bakeryMetrics.operational.food_safety_score.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Seguridad Alimentaria</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-red-600">
|
|
||||||
{bakeryMetrics.inventory.stockout_rate.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Roturas Stock</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-indigo-600">
|
|
||||||
{bakeryMetrics.customer.customer_retention_rate.toFixed(1)}%
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Retención Clientes</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-pink-600">
|
|
||||||
€{bakeryMetrics.customer.customer_lifetime_value.toFixed(0)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Valor Cliente</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-teal-600">
|
|
||||||
{bakeryMetrics.inventory.turnover_rate.toFixed(1)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">Rotación Inventario</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderViewContent = () => {
|
|
||||||
switch (activeView) {
|
|
||||||
case 'overview':
|
|
||||||
return renderOverviewDashboard();
|
|
||||||
case 'reports':
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<ReportsTable
|
|
||||||
reports={reports}
|
|
||||||
loading={loading}
|
|
||||||
onReportClick={(report) => console.log('View report:', report)}
|
|
||||||
onEditReport={(report) => console.log('Edit report:', report)}
|
|
||||||
onDeleteReport={(id) => console.log('Delete report:', id)}
|
|
||||||
onExportReport={(report, format) => console.log('Export report:', report, format)}
|
|
||||||
bulkActions={true}
|
|
||||||
sortable={true}
|
|
||||||
pagination={{
|
|
||||||
current: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
total: reports.length,
|
|
||||||
onChange: (page, pageSize) => console.log('Page change:', page, pageSize)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<p className="text-gray-500">Vista en desarrollo: {activeView}</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-2">
|
|
||||||
Esta vista estará disponible próximamente con métricas detalladas.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
|
|
||||||
<p className="text-gray-600">Análisis integral de rendimiento de la panadería</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedTimeRange}
|
|
||||||
onChange={(e) => handleTimeRangeChange(e.target.value as TimeRange)}
|
|
||||||
className="w-48"
|
|
||||||
>
|
|
||||||
{DEFAULT_TIME_RANGES.map((range) => (
|
|
||||||
<option key={range.value} value={range.value}>
|
|
||||||
{range.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{selectedTimeRange === 'custom' && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<DatePicker
|
|
||||||
value={customDateRange?.from}
|
|
||||||
onChange={(date) => setCustomDateRange(prev => ({
|
|
||||||
...prev,
|
|
||||||
from: date || new Date()
|
|
||||||
} as { from: Date; to: Date }))}
|
|
||||||
placeholder="Fecha inicio"
|
|
||||||
/>
|
|
||||||
<DatePicker
|
|
||||||
value={customDateRange?.to}
|
|
||||||
onChange={(date) => setCustomDateRange(prev => ({
|
|
||||||
...prev,
|
|
||||||
to: date || new Date()
|
|
||||||
} as { from: Date; to: Date }))}
|
|
||||||
placeholder="Fecha fin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showExport && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsExportModalOpen(true)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
📊 Exportar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={loadAnalyticsData}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? '🔄 Cargando...' : '🔄 Actualizar'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8">
|
|
||||||
{[
|
|
||||||
{ key: 'overview', label: 'Resumen', icon: '📊' },
|
|
||||||
{ key: 'sales', label: 'Ventas', icon: '💰' },
|
|
||||||
{ key: 'production', label: 'Producción', icon: '🏭' },
|
|
||||||
{ key: 'inventory', label: 'Inventario', icon: '📦' },
|
|
||||||
{ key: 'financial', label: 'Financiero', icon: '💳' },
|
|
||||||
{ key: 'reports', label: 'Reportes', icon: '📋' },
|
|
||||||
].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveView(tab.key as any)}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
|
||||||
activeView === tab.key
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="mr-1">{tab.icon}</span>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
renderViewContent()
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Export Modal */}
|
|
||||||
{isExportModalOpen && (
|
|
||||||
<ExportOptions
|
|
||||||
type="report"
|
|
||||||
title={`Analytics Dashboard - ${activeView}`}
|
|
||||||
description="Exportar datos del dashboard de analytics"
|
|
||||||
onExport={handleExport}
|
|
||||||
loading={false}
|
|
||||||
showScheduling={true}
|
|
||||||
showTemplates={true}
|
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
|
||||||
onClose={() => setIsExportModalOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalyticsDashboard;
|
|
||||||
@@ -1,660 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
||||||
import { Card, Button, Badge, Select, Modal } from '../../ui';
|
|
||||||
import type {
|
|
||||||
ChartWidget as ChartWidgetType,
|
|
||||||
ChartSeries,
|
|
||||||
ChartConfig,
|
|
||||||
ChartFilter,
|
|
||||||
ExportFormat,
|
|
||||||
ChartType
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
interface ChartWidgetProps {
|
|
||||||
widget: ChartWidgetType;
|
|
||||||
data?: ChartSeries[];
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string;
|
|
||||||
onConfigChange?: (config: Partial<ChartConfig>) => void;
|
|
||||||
onFiltersChange?: (filters: ChartFilter[]) => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
onExport?: (format: ExportFormat) => void;
|
|
||||||
onFullscreen?: () => void;
|
|
||||||
interactive?: boolean;
|
|
||||||
showControls?: boolean;
|
|
||||||
showTitle?: boolean;
|
|
||||||
showSubtitle?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChartPoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHART_TYPE_LABELS: Record<ChartType, string> = {
|
|
||||||
line: 'Líneas',
|
|
||||||
bar: 'Barras',
|
|
||||||
pie: 'Circular',
|
|
||||||
area: 'Área',
|
|
||||||
scatter: 'Dispersión',
|
|
||||||
doughnut: 'Dona',
|
|
||||||
radar: 'Radar',
|
|
||||||
mixed: 'Mixto',
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXPORT_FORMATS: ExportFormat[] = ['png', 'svg', 'pdf', 'csv', 'excel'];
|
|
||||||
|
|
||||||
export const ChartWidget: React.FC<ChartWidgetProps> = ({
|
|
||||||
widget,
|
|
||||||
data = [],
|
|
||||||
loading = false,
|
|
||||||
error,
|
|
||||||
onConfigChange,
|
|
||||||
onFiltersChange,
|
|
||||||
onRefresh,
|
|
||||||
onExport,
|
|
||||||
onFullscreen,
|
|
||||||
interactive = true,
|
|
||||||
showControls = true,
|
|
||||||
showTitle = true,
|
|
||||||
showSubtitle = true,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const [hoveredPoint, setHoveredPoint] = useState<{ seriesIndex: number; pointIndex: number } | null>(null);
|
|
||||||
const [selectedExportFormat, setSelectedExportFormat] = useState<ExportFormat>('png');
|
|
||||||
|
|
||||||
const chartDimensions = useMemo(() => ({
|
|
||||||
width: widget.dimensions?.width || '100%',
|
|
||||||
height: widget.dimensions?.height || 400,
|
|
||||||
aspectRatio: widget.dimensions?.aspect_ratio || '16:9',
|
|
||||||
}), [widget.dimensions]);
|
|
||||||
|
|
||||||
// Simple chart rendering logic
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current || !data.length || loading) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
// Set canvas size
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width;
|
|
||||||
canvas.height = rect.height;
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Draw based on chart type
|
|
||||||
switch (widget.type) {
|
|
||||||
case 'bar':
|
|
||||||
drawBarChart(ctx, canvas, data);
|
|
||||||
break;
|
|
||||||
case 'line':
|
|
||||||
drawLineChart(ctx, canvas, data);
|
|
||||||
break;
|
|
||||||
case 'pie':
|
|
||||||
drawPieChart(ctx, canvas, data);
|
|
||||||
break;
|
|
||||||
case 'area':
|
|
||||||
drawAreaChart(ctx, canvas, data);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
drawBarChart(ctx, canvas, data);
|
|
||||||
}
|
|
||||||
}, [data, widget.type, loading]);
|
|
||||||
|
|
||||||
const drawBarChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
|
||||||
if (!chartData.length) return;
|
|
||||||
|
|
||||||
const padding = 60;
|
|
||||||
const chartWidth = canvas.width - 2 * padding;
|
|
||||||
const chartHeight = canvas.height - 2 * padding;
|
|
||||||
|
|
||||||
// Get all data points
|
|
||||||
const allPoints = chartData.flatMap(series => series.data);
|
|
||||||
const maxY = Math.max(...allPoints.map(point => point.y));
|
|
||||||
const minY = Math.min(...allPoints.map(point => point.y));
|
|
||||||
|
|
||||||
const firstSeries = chartData[0];
|
|
||||||
const barWidth = chartWidth / firstSeries.data.length / chartData.length;
|
|
||||||
const barSpacing = barWidth * 0.1;
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
ctx.strokeStyle = '#e5e7eb';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let i = 0; i <= 5; i++) {
|
|
||||||
const y = padding + (chartHeight * i) / 5;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding, y);
|
|
||||||
ctx.lineTo(padding + chartWidth, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw bars
|
|
||||||
chartData.forEach((series, seriesIndex) => {
|
|
||||||
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
|
|
||||||
series.data.forEach((point, pointIndex) => {
|
|
||||||
const x = padding + (pointIndex * chartWidth) / firstSeries.data.length + (seriesIndex * barWidth);
|
|
||||||
const barHeight = ((point.y - minY) / (maxY - minY)) * chartHeight;
|
|
||||||
const y = padding + chartHeight - barHeight;
|
|
||||||
|
|
||||||
ctx.fillRect(x + barSpacing, y, barWidth - barSpacing, barHeight);
|
|
||||||
|
|
||||||
// Add value labels if enabled
|
|
||||||
if (widget.config.show_tooltips) {
|
|
||||||
ctx.fillStyle = '#374151';
|
|
||||||
ctx.font = '12px sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(point.y.toString(), x + barWidth / 2, y - 5);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw axes
|
|
||||||
ctx.strokeStyle = '#374151';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding, padding);
|
|
||||||
ctx.lineTo(padding, padding + chartHeight);
|
|
||||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw labels
|
|
||||||
ctx.fillStyle = '#6b7280';
|
|
||||||
ctx.font = '12px sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
|
|
||||||
// X-axis labels
|
|
||||||
firstSeries.data.forEach((point, index) => {
|
|
||||||
const x = padding + (index + 0.5) * (chartWidth / firstSeries.data.length);
|
|
||||||
ctx.fillText(point.x.toString(), x, padding + chartHeight + 20);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Y-axis labels
|
|
||||||
ctx.textAlign = 'right';
|
|
||||||
for (let i = 0; i <= 5; i++) {
|
|
||||||
const y = padding + (chartHeight * i) / 5;
|
|
||||||
const value = maxY - ((maxY - minY) * i) / 5;
|
|
||||||
ctx.fillText(value.toFixed(0), padding - 10, y + 4);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawLineChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
|
||||||
if (!chartData.length) return;
|
|
||||||
|
|
||||||
const padding = 60;
|
|
||||||
const chartWidth = canvas.width - 2 * padding;
|
|
||||||
const chartHeight = canvas.height - 2 * padding;
|
|
||||||
|
|
||||||
const allPoints = chartData.flatMap(series => series.data);
|
|
||||||
const maxY = Math.max(...allPoints.map(point => point.y));
|
|
||||||
const minY = Math.min(...allPoints.map(point => point.y));
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
ctx.strokeStyle = '#e5e7eb';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let i = 0; i <= 5; i++) {
|
|
||||||
const y = padding + (chartHeight * i) / 5;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding, y);
|
|
||||||
ctx.lineTo(padding + chartWidth, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw lines
|
|
||||||
chartData.forEach((series, seriesIndex) => {
|
|
||||||
if (!series.visible) return;
|
|
||||||
|
|
||||||
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
series.data.forEach((point, pointIndex) => {
|
|
||||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
|
||||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
|
||||||
|
|
||||||
if (pointIndex === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw points
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
series.data.forEach((point, pointIndex) => {
|
|
||||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
|
||||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw axes
|
|
||||||
ctx.strokeStyle = '#374151';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding, padding);
|
|
||||||
ctx.lineTo(padding, padding + chartHeight);
|
|
||||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
|
||||||
ctx.stroke();
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawPieChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
|
||||||
if (!chartData.length) return;
|
|
||||||
|
|
||||||
const centerX = canvas.width / 2;
|
|
||||||
const centerY = canvas.height / 2;
|
|
||||||
const radius = Math.min(canvas.width, canvas.height) / 3;
|
|
||||||
|
|
||||||
const firstSeries = chartData[0];
|
|
||||||
const total = firstSeries.data.reduce((sum, point) => sum + point.y, 0);
|
|
||||||
|
|
||||||
let startAngle = -Math.PI / 2;
|
|
||||||
|
|
||||||
firstSeries.data.forEach((point, index) => {
|
|
||||||
const sliceAngle = (point.y / total) * 2 * Math.PI;
|
|
||||||
const color = widget.config.color_scheme[index % widget.config.color_scheme.length];
|
|
||||||
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(centerX, centerY);
|
|
||||||
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Draw slice border
|
|
||||||
ctx.strokeStyle = '#ffffff';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw labels
|
|
||||||
const labelAngle = startAngle + sliceAngle / 2;
|
|
||||||
const labelX = centerX + Math.cos(labelAngle) * (radius + 30);
|
|
||||||
const labelY = centerY + Math.sin(labelAngle) * (radius + 30);
|
|
||||||
|
|
||||||
ctx.fillStyle = '#374151';
|
|
||||||
ctx.font = '12px sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(point.x.toString(), labelX, labelY);
|
|
||||||
|
|
||||||
const percentage = ((point.y / total) * 100).toFixed(1);
|
|
||||||
ctx.fillText(`${percentage}%`, labelX, labelY + 15);
|
|
||||||
|
|
||||||
startAngle += sliceAngle;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawAreaChart = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, chartData: ChartSeries[]) => {
|
|
||||||
if (!chartData.length) return;
|
|
||||||
|
|
||||||
const padding = 60;
|
|
||||||
const chartWidth = canvas.width - 2 * padding;
|
|
||||||
const chartHeight = canvas.height - 2 * padding;
|
|
||||||
|
|
||||||
const allPoints = chartData.flatMap(series => series.data);
|
|
||||||
const maxY = Math.max(...allPoints.map(point => point.y));
|
|
||||||
const minY = Math.min(...allPoints.map(point => point.y));
|
|
||||||
|
|
||||||
chartData.forEach((series, seriesIndex) => {
|
|
||||||
if (!series.visible) return;
|
|
||||||
|
|
||||||
const color = series.color || widget.config.color_scheme[seriesIndex % widget.config.color_scheme.length];
|
|
||||||
|
|
||||||
// Create gradient
|
|
||||||
const gradient = ctx.createLinearGradient(0, padding, 0, padding + chartHeight);
|
|
||||||
gradient.addColorStop(0, color + '80'); // Semi-transparent
|
|
||||||
gradient.addColorStop(1, color + '10'); // Very transparent
|
|
||||||
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
// Start from bottom-left
|
|
||||||
const firstX = padding;
|
|
||||||
const firstY = padding + chartHeight;
|
|
||||||
ctx.moveTo(firstX, firstY);
|
|
||||||
|
|
||||||
// Draw the area path
|
|
||||||
series.data.forEach((point, pointIndex) => {
|
|
||||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
|
||||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close the path at bottom-right
|
|
||||||
const lastX = padding + chartWidth;
|
|
||||||
const lastY = padding + chartHeight;
|
|
||||||
ctx.lineTo(lastX, lastY);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Draw the line on top
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
series.data.forEach((point, pointIndex) => {
|
|
||||||
const x = padding + (pointIndex * chartWidth) / (series.data.length - 1);
|
|
||||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
|
||||||
|
|
||||||
if (pointIndex === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
if (onExport) {
|
|
||||||
onExport(selectedExportFormat);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFullscreen = () => {
|
|
||||||
setIsFullscreen(!isFullscreen);
|
|
||||||
if (onFullscreen) {
|
|
||||||
onFullscreen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLegend = () => {
|
|
||||||
if (!widget.config.show_legend || !data.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-4 mt-4">
|
|
||||||
{data.map((series, index) => (
|
|
||||||
<div key={series.id} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: series.color || widget.config.color_scheme[index % widget.config.color_scheme.length]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-600">{series.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderControls = () => {
|
|
||||||
if (!showControls || !interactive) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsConfigModalOpen(true)}
|
|
||||||
title="Configurar gráfico"
|
|
||||||
>
|
|
||||||
⚙️
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={selectedExportFormat}
|
|
||||||
onChange={(e) => setSelectedExportFormat(e.target.value as ExportFormat)}
|
|
||||||
className="w-20 text-xs"
|
|
||||||
>
|
|
||||||
{EXPORT_FORMATS.map(format => (
|
|
||||||
<option key={format} value={format}>{format.toUpperCase()}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleExport}
|
|
||||||
title="Exportar gráfico"
|
|
||||||
>
|
|
||||||
📥
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleFullscreen}
|
|
||||||
title="Pantalla completa"
|
|
||||||
>
|
|
||||||
{isFullscreen ? '📋' : '🔍'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{onRefresh && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
title="Actualizar datos"
|
|
||||||
>
|
|
||||||
🔄
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={`p-6 ${className}`}>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-red-600 font-medium">Error al cargar el gráfico</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{error}</p>
|
|
||||||
{onRefresh && (
|
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} className="mt-3">
|
|
||||||
Reintentar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className={`group relative ${className}`} style={{
|
|
||||||
width: chartDimensions.width,
|
|
||||||
height: isFullscreen ? '90vh' : chartDimensions.height
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between p-4 pb-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
{showTitle && (
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
{widget.title}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
{showSubtitle && widget.subtitle && (
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
{widget.subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderControls()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart Area */}
|
|
||||||
<div className="px-4 pb-4" style={{ height: `calc(100% - 80px)` }}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-500">No hay datos disponibles</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
Ajusta los filtros o verifica la conexión de datos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-full">
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="w-full h-full"
|
|
||||||
style={{ maxHeight: '100%' }}
|
|
||||||
/>
|
|
||||||
{renderLegend()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Updated */}
|
|
||||||
{widget.last_updated && (
|
|
||||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
|
||||||
Actualizado: {new Date(widget.last_updated).toLocaleTimeString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Configuration Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isConfigModalOpen}
|
|
||||||
onClose={() => setIsConfigModalOpen(false)}
|
|
||||||
title="Configuración del Gráfico"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Tipo de gráfico
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={widget.type}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (onConfigChange) {
|
|
||||||
onConfigChange({ visualization_type: e.target.value as ChartType });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(CHART_TYPE_LABELS).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={widget.config.show_legend}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (onConfigChange) {
|
|
||||||
onConfigChange({ show_legend: e.target.checked });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
Mostrar leyenda
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={widget.config.show_grid}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (onConfigChange) {
|
|
||||||
onConfigChange({ show_grid: e.target.checked });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
Mostrar cuadrícula
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={widget.config.show_tooltips}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (onConfigChange) {
|
|
||||||
onConfigChange({ show_tooltips: e.target.checked });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
Mostrar tooltips
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={widget.config.animation_enabled}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (onConfigChange) {
|
|
||||||
onConfigChange({ animation_enabled: e.target.checked });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
Animaciones
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Posición de la leyenda
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={widget.config.legend_position}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (onConfigChange) {
|
|
||||||
onConfigChange({ legend_position: e.target.value as any });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="top">Arriba</option>
|
|
||||||
<option value="bottom">Abajo</option>
|
|
||||||
<option value="left">Izquierda</option>
|
|
||||||
<option value="right">Derecha</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsConfigModalOpen(false)}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
setIsConfigModalOpen(false);
|
|
||||||
if (onRefresh) onRefresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Aplicar cambios
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChartWidget;
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Card, Button, Badge, Input, Select } from '../../ui';
|
|
||||||
import type {
|
|
||||||
ExportOptionsProps,
|
|
||||||
ExportOptions,
|
|
||||||
ExportFormat,
|
|
||||||
ExportTemplate,
|
|
||||||
ReportSchedule
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const FORMAT_LABELS: Record<ExportFormat, string> = {
|
|
||||||
pdf: 'PDF',
|
|
||||||
excel: 'Excel',
|
|
||||||
csv: 'CSV',
|
|
||||||
png: 'PNG',
|
|
||||||
svg: 'SVG',
|
|
||||||
json: 'JSON',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FORMAT_DESCRIPTIONS: Record<ExportFormat, string> = {
|
|
||||||
pdf: 'Documento PDF con formato profesional',
|
|
||||||
excel: 'Hoja de cálculo de Microsoft Excel',
|
|
||||||
csv: 'Archivo de valores separados por comas',
|
|
||||||
png: 'Imagen PNG de alta calidad',
|
|
||||||
svg: 'Imagen vectorial escalable',
|
|
||||||
json: 'Datos estructurados en formato JSON',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FORMAT_ICONS: Record<ExportFormat, string> = {
|
|
||||||
pdf: '📄',
|
|
||||||
excel: '📊',
|
|
||||||
csv: '📋',
|
|
||||||
png: '🖼️',
|
|
||||||
svg: '🎨',
|
|
||||||
json: '⚙️',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FREQUENCY_OPTIONS = [
|
|
||||||
{ value: 'daily', label: 'Diario' },
|
|
||||||
{ value: 'weekly', label: 'Semanal' },
|
|
||||||
{ value: 'monthly', label: 'Mensual' },
|
|
||||||
{ value: 'quarterly', label: 'Trimestral' },
|
|
||||||
{ value: 'yearly', label: 'Anual' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TIME_OPTIONS = [
|
|
||||||
{ value: '06:00', label: '6:00 AM' },
|
|
||||||
{ value: '09:00', label: '9:00 AM' },
|
|
||||||
{ value: '12:00', label: '12:00 PM' },
|
|
||||||
{ value: '15:00', label: '3:00 PM' },
|
|
||||||
{ value: '18:00', label: '6:00 PM' },
|
|
||||||
{ value: '21:00', label: '9:00 PM' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DAYS_OF_WEEK = [
|
|
||||||
{ value: 1, label: 'Lunes' },
|
|
||||||
{ value: 2, label: 'Martes' },
|
|
||||||
{ value: 3, label: 'Miércoles' },
|
|
||||||
{ value: 4, label: 'Jueves' },
|
|
||||||
{ value: 5, label: 'Viernes' },
|
|
||||||
{ value: 6, label: 'Sábado' },
|
|
||||||
{ value: 0, label: 'Domingo' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ExportOptionsComponentProps extends ExportOptionsProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ExportOptions: React.FC<ExportOptionsComponentProps> = ({
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
availableFormats = ['pdf', 'excel', 'csv', 'png'],
|
|
||||||
templates = [],
|
|
||||||
onExport,
|
|
||||||
onSchedule,
|
|
||||||
loading = false,
|
|
||||||
disabled = false,
|
|
||||||
showScheduling = true,
|
|
||||||
showTemplates = true,
|
|
||||||
showAdvanced = true,
|
|
||||||
defaultOptions,
|
|
||||||
className = '',
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>(
|
|
||||||
defaultOptions?.format || availableFormats[0]
|
|
||||||
);
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(
|
|
||||||
templates.find(t => t.is_default)?.id || ''
|
|
||||||
);
|
|
||||||
const [exportOptions, setExportOptions] = useState<Partial<ExportOptions>>({
|
|
||||||
include_headers: true,
|
|
||||||
include_filters: true,
|
|
||||||
include_summary: true,
|
|
||||||
date_format: 'DD/MM/YYYY',
|
|
||||||
number_format: '#,##0.00',
|
|
||||||
currency_format: '€#,##0.00',
|
|
||||||
locale: 'es-ES',
|
|
||||||
timezone: 'Europe/Madrid',
|
|
||||||
page_size: 'A4',
|
|
||||||
orientation: 'portrait',
|
|
||||||
password_protected: false,
|
|
||||||
...defaultOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [scheduleOptions, setScheduleOptions] = useState<Partial<ReportSchedule>>({
|
|
||||||
enabled: false,
|
|
||||||
frequency: 'weekly',
|
|
||||||
time: '09:00',
|
|
||||||
days_of_week: [1], // Monday
|
|
||||||
recipients: [],
|
|
||||||
format: selectedFormat,
|
|
||||||
include_attachments: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [recipientsInput, setRecipientsInput] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState<'export' | 'schedule'>('export');
|
|
||||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
|
||||||
|
|
||||||
const handleExportOptionChange = (key: keyof ExportOptions, value: any) => {
|
|
||||||
setExportOptions(prev => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScheduleOptionChange = (key: keyof ReportSchedule, value: any) => {
|
|
||||||
setScheduleOptions(prev => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
if (onExport) {
|
|
||||||
const fullOptions: ExportOptions = {
|
|
||||||
format: selectedFormat,
|
|
||||||
template: selectedTemplate || undefined,
|
|
||||||
include_headers: true,
|
|
||||||
include_filters: true,
|
|
||||||
include_summary: true,
|
|
||||||
date_format: 'DD/MM/YYYY',
|
|
||||||
number_format: '#,##0.00',
|
|
||||||
currency_format: '€#,##0.00',
|
|
||||||
locale: 'es-ES',
|
|
||||||
timezone: 'Europe/Madrid',
|
|
||||||
page_size: 'A4',
|
|
||||||
orientation: 'portrait',
|
|
||||||
...exportOptions,
|
|
||||||
// format: selectedFormat, // Ensure format matches selection - handled by exportOptions
|
|
||||||
};
|
|
||||||
onExport(fullOptions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSchedule = () => {
|
|
||||||
if (onSchedule) {
|
|
||||||
const recipients = recipientsInput
|
|
||||||
.split(',')
|
|
||||||
.map(email => email.trim())
|
|
||||||
.filter(email => email.length > 0);
|
|
||||||
|
|
||||||
const fullSchedule: ReportSchedule = {
|
|
||||||
enabled: true,
|
|
||||||
frequency: 'weekly',
|
|
||||||
time: '09:00',
|
|
||||||
recipients: recipients,
|
|
||||||
format: selectedFormat,
|
|
||||||
include_attachments: true,
|
|
||||||
...scheduleOptions,
|
|
||||||
};
|
|
||||||
onSchedule(fullSchedule);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFormatSelector = () => (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="font-medium text-gray-900">Formato de exportación</h4>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{availableFormats.map(format => (
|
|
||||||
<button
|
|
||||||
key={format}
|
|
||||||
onClick={() => setSelectedFormat(format)}
|
|
||||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
|
||||||
selectedFormat === format
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xl">{FORMAT_ICONS[format]}</span>
|
|
||||||
<span className="font-medium">{FORMAT_LABELS[format]}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">{FORMAT_DESCRIPTIONS[format]}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTemplateSelector = () => {
|
|
||||||
if (!showTemplates || templates.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="font-medium text-gray-900">Plantilla</h4>
|
|
||||||
<Select
|
|
||||||
value={selectedTemplate}
|
|
||||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
<option value="">Plantilla predeterminada</option>
|
|
||||||
{templates.map(template => (
|
|
||||||
<option key={template.id} value={template.id}>
|
|
||||||
{template.name}
|
|
||||||
{template.is_default && ' (Predeterminada)'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{selectedTemplate && (
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{templates.find(t => t.id === selectedTemplate)?.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBasicOptions = () => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="font-medium text-gray-900">Opciones básicas</h4>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportOptions.include_headers || false}
|
|
||||||
onChange={(e) => handleExportOptionChange('include_headers', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
Incluir encabezados de columna
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportOptions.include_filters || false}
|
|
||||||
onChange={(e) => handleExportOptionChange('include_filters', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
Incluir información de filtros aplicados
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportOptions.include_summary || false}
|
|
||||||
onChange={(e) => handleExportOptionChange('include_summary', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
Incluir resumen y estadísticas
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderAdvancedOptions = () => {
|
|
||||||
if (!showAdvanced || !showAdvancedOptions) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 pt-4 border-t border-gray-200">
|
|
||||||
<h4 className="font-medium text-gray-900">Opciones avanzadas</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Formato de fecha
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={exportOptions.date_format || 'DD/MM/YYYY'}
|
|
||||||
onChange={(e) => handleExportOptionChange('date_format', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
|
||||||
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
|
||||||
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
|
||||||
<option value="DD-MMM-YYYY">DD-MMM-YYYY</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Formato de números
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={exportOptions.number_format || '#,##0.00'}
|
|
||||||
onChange={(e) => handleExportOptionChange('number_format', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
<option value="#,##0.00">#,##0.00</option>
|
|
||||||
<option value="#.##0,00">#.##0,00</option>
|
|
||||||
<option value="#,##0">#,##0</option>
|
|
||||||
<option value="0.00">0.00</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(['pdf'] as ExportFormat[]).includes(selectedFormat) && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Tamaño de página
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={exportOptions.page_size || 'A4'}
|
|
||||||
onChange={(e) => handleExportOptionChange('page_size', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
<option value="A4">A4</option>
|
|
||||||
<option value="A3">A3</option>
|
|
||||||
<option value="Letter">Carta</option>
|
|
||||||
<option value="Legal">Legal</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Orientación
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={exportOptions.orientation || 'portrait'}
|
|
||||||
onChange={(e) => handleExportOptionChange('orientation', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
<option value="portrait">Vertical</option>
|
|
||||||
<option value="landscape">Horizontal</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportOptions.password_protected || false}
|
|
||||||
onChange={(e) => handleExportOptionChange('password_protected', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
Proteger con contraseña
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{exportOptions.password_protected && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Contraseña"
|
|
||||||
value={exportOptions.password || ''}
|
|
||||||
onChange={(e) => handleExportOptionChange('password', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderScheduleTab = () => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center mb-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={scheduleOptions.enabled || false}
|
|
||||||
onChange={(e) => handleScheduleOptionChange('enabled', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-gray-900">Habilitar programación automática</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scheduleOptions.enabled && (
|
|
||||||
<div className="space-y-4 pl-6 border-l-2 border-blue-200">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Frecuencia
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={scheduleOptions.frequency || 'weekly'}
|
|
||||||
onChange={(e) => handleScheduleOptionChange('frequency', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
{FREQUENCY_OPTIONS.map(option => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Hora de envío
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={scheduleOptions.time || '09:00'}
|
|
||||||
onChange={(e) => handleScheduleOptionChange('time', e.target.value)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
{TIME_OPTIONS.map(option => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scheduleOptions.frequency === 'weekly' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Días de la semana
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DAYS_OF_WEEK.map(day => (
|
|
||||||
<label key={day.value} className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(scheduleOptions.days_of_week || []).includes(day.value)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const currentDays = scheduleOptions.days_of_week || [];
|
|
||||||
const newDays = e.target.checked
|
|
||||||
? [...currentDays, day.value]
|
|
||||||
: currentDays.filter(d => d !== day.value);
|
|
||||||
handleScheduleOptionChange('days_of_week', newDays);
|
|
||||||
}}
|
|
||||||
className="mr-1 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{day.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Destinatarios (emails separados por comas)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={recipientsInput}
|
|
||||||
onChange={(e) => setRecipientsInput(e.target.value)}
|
|
||||||
placeholder="ejemplo@empresa.com, otro@empresa.com"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={scheduleOptions.include_attachments || false}
|
|
||||||
onChange={(e) => handleScheduleOptionChange('include_attachments', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
disabled={disabled || loading}
|
|
||||||
/>
|
|
||||||
Incluir archivo adjunto con los datos
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-white rounded-lg shadow-lg max-w-2xl mx-auto ${className}`}>
|
|
||||||
<Card className="p-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{onClose && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8 px-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('export')}
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'export'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
📥 Exportación inmediata
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showScheduling && (
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('schedule')}
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'schedule'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
📅 Programación
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6 space-y-6" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
|
||||||
{activeTab === 'export' ? (
|
|
||||||
<>
|
|
||||||
{renderFormatSelector()}
|
|
||||||
{renderTemplateSelector()}
|
|
||||||
{renderBasicOptions()}
|
|
||||||
|
|
||||||
{showAdvanced && (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
|
||||||
className="text-blue-600"
|
|
||||||
>
|
|
||||||
{showAdvancedOptions ? '▲' : '▼'} Opciones avanzadas
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderAdvancedOptions()}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
renderScheduleTab()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-200">
|
|
||||||
{onClose && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'export' ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
{loading ? '🔄 Exportando...' : `${FORMAT_ICONS[selectedFormat]} Exportar ${FORMAT_LABELS[selectedFormat]}`}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleSchedule}
|
|
||||||
disabled={disabled || loading || !scheduleOptions.enabled || !recipientsInput.trim()}
|
|
||||||
>
|
|
||||||
{loading ? '🔄 Programando...' : '📅 Programar reporte'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExportOptions;
|
|
||||||
@@ -1,632 +0,0 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import { Card, Button, Badge, Input, Select, DatePicker } from '../../ui';
|
|
||||||
import type {
|
|
||||||
FilterPanelProps,
|
|
||||||
AnalyticsFilter,
|
|
||||||
FilterOption,
|
|
||||||
AppliedFilter,
|
|
||||||
FilterPreset,
|
|
||||||
FilterType
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const FILTER_TYPE_ICONS: Record<FilterType, string> = {
|
|
||||||
date: '📅',
|
|
||||||
select: '📋',
|
|
||||||
multiselect: '☑️',
|
|
||||||
range: '↔️',
|
|
||||||
text: '🔍',
|
|
||||||
number: '#️⃣',
|
|
||||||
boolean: '✓',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FilterPanel: React.FC<FilterPanelProps> = ({
|
|
||||||
panel,
|
|
||||||
appliedFilters = [],
|
|
||||||
onFiltersChange,
|
|
||||||
onPresetSelect,
|
|
||||||
onPresetSave,
|
|
||||||
onPresetDelete,
|
|
||||||
onReset,
|
|
||||||
loading = false,
|
|
||||||
disabled = false,
|
|
||||||
compact = false,
|
|
||||||
showPresets = true,
|
|
||||||
showReset = true,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(panel.default_collapsed);
|
|
||||||
const [localFilters, setLocalFilters] = useState<Record<string, any>>({});
|
|
||||||
const [isPresetModalOpen, setIsPresetModalOpen] = useState(false);
|
|
||||||
const [newPresetName, setNewPresetName] = useState('');
|
|
||||||
const [newPresetDescription, setNewPresetDescription] = useState('');
|
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const appliedFiltersMap = useMemo(() => {
|
|
||||||
const map: Record<string, AppliedFilter> = {};
|
|
||||||
appliedFilters.forEach(filter => {
|
|
||||||
map[filter.filter_id] = filter;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [appliedFilters]);
|
|
||||||
|
|
||||||
const getFilterValue = useCallback((filter: AnalyticsFilter) => {
|
|
||||||
const applied = appliedFiltersMap[filter.id];
|
|
||||||
if (applied) {
|
|
||||||
return applied.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const local = localFilters[filter.id];
|
|
||||||
if (local !== undefined) {
|
|
||||||
return local;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter.default_value;
|
|
||||||
}, [appliedFiltersMap, localFilters]);
|
|
||||||
|
|
||||||
const updateFilterValue = useCallback((filterId: string, value: any) => {
|
|
||||||
setLocalFilters(prev => ({ ...prev, [filterId]: value }));
|
|
||||||
|
|
||||||
// Clear validation error for this filter
|
|
||||||
setValidationErrors(prev => {
|
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors[filterId];
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateFilter = useCallback((filter: AnalyticsFilter, value: any): string | null => {
|
|
||||||
if (!filter.validation_rules) return null;
|
|
||||||
|
|
||||||
for (const rule of filter.validation_rules) {
|
|
||||||
switch (rule.rule) {
|
|
||||||
case 'required':
|
|
||||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
|
||||||
return rule.message;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'min':
|
|
||||||
if (typeof value === 'number' && value < rule.value) {
|
|
||||||
return rule.message;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' && value.length < rule.value) {
|
|
||||||
return rule.message;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'max':
|
|
||||||
if (typeof value === 'number' && value > rule.value) {
|
|
||||||
return rule.message;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' && value.length > rule.value) {
|
|
||||||
return rule.message;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'pattern':
|
|
||||||
if (typeof value === 'string' && !new RegExp(rule.value).test(value)) {
|
|
||||||
return rule.message;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyFilters = useCallback(() => {
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
const newAppliedFilters: AppliedFilter[] = [];
|
|
||||||
|
|
||||||
panel.filters.forEach(filter => {
|
|
||||||
const value = getFilterValue(filter);
|
|
||||||
|
|
||||||
// Validate the filter
|
|
||||||
const validationError = validateFilter(filter, value);
|
|
||||||
if (validationError) {
|
|
||||||
errors[filter.id] = validationError;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if no value and not required
|
|
||||||
if ((value === undefined || value === null || value === '') && !filter.required) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create applied filter
|
|
||||||
const appliedFilter: AppliedFilter = {
|
|
||||||
filter_id: filter.id,
|
|
||||||
field: filter.field,
|
|
||||||
operator: getOperatorForFilterType(filter.type),
|
|
||||||
value,
|
|
||||||
display_value: formatDisplayValue(filter, value),
|
|
||||||
};
|
|
||||||
|
|
||||||
newAppliedFilters.push(appliedFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
setValidationErrors(errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length === 0 && onFiltersChange) {
|
|
||||||
onFiltersChange(newAppliedFilters);
|
|
||||||
}
|
|
||||||
}, [panel.filters, getFilterValue, validateFilter, onFiltersChange]);
|
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
|
||||||
setLocalFilters({});
|
|
||||||
setValidationErrors({});
|
|
||||||
if (onReset) {
|
|
||||||
onReset();
|
|
||||||
}
|
|
||||||
}, [onReset]);
|
|
||||||
|
|
||||||
const getOperatorForFilterType = (type: FilterType): string => {
|
|
||||||
switch (type) {
|
|
||||||
case 'select':
|
|
||||||
case 'boolean':
|
|
||||||
return 'eq';
|
|
||||||
case 'multiselect':
|
|
||||||
return 'in';
|
|
||||||
case 'range':
|
|
||||||
return 'between';
|
|
||||||
case 'text':
|
|
||||||
return 'like';
|
|
||||||
case 'number':
|
|
||||||
case 'date':
|
|
||||||
return 'eq';
|
|
||||||
default:
|
|
||||||
return 'eq';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDisplayValue = (filter: AnalyticsFilter, value: any): string => {
|
|
||||||
if (value === undefined || value === null) return '';
|
|
||||||
|
|
||||||
switch (filter.type) {
|
|
||||||
case 'select':
|
|
||||||
case 'multiselect':
|
|
||||||
if (filter.options) {
|
|
||||||
const options = Array.isArray(value) ? value : [value];
|
|
||||||
return options
|
|
||||||
.map(v => filter.options?.find(opt => opt.value === v)?.label || v)
|
|
||||||
.join(', ');
|
|
||||||
}
|
|
||||||
return Array.isArray(value) ? value.join(', ') : String(value);
|
|
||||||
case 'boolean':
|
|
||||||
return value ? 'Sí' : 'No';
|
|
||||||
case 'date':
|
|
||||||
return new Date(value).toLocaleDateString('es-ES');
|
|
||||||
case 'range':
|
|
||||||
return Array.isArray(value) ? `${value[0]} - ${value[1]}` : String(value);
|
|
||||||
default:
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePresetSelect = (presetId: string) => {
|
|
||||||
const preset = panel.presets.find(p => p.id === presetId);
|
|
||||||
if (preset) {
|
|
||||||
setLocalFilters(preset.values);
|
|
||||||
if (onPresetSelect) {
|
|
||||||
onPresetSelect(presetId);
|
|
||||||
}
|
|
||||||
// Auto-apply filters when preset is selected
|
|
||||||
setTimeout(applyFilters, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePresetSave = () => {
|
|
||||||
if (!newPresetName.trim()) return;
|
|
||||||
|
|
||||||
const currentValues: Record<string, any> = {};
|
|
||||||
panel.filters.forEach(filter => {
|
|
||||||
const value = getFilterValue(filter);
|
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
|
||||||
currentValues[filter.id] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const newPreset: Omit<FilterPreset, 'id'> = {
|
|
||||||
name: newPresetName.trim(),
|
|
||||||
description: newPresetDescription.trim() || undefined,
|
|
||||||
is_default: false,
|
|
||||||
is_public: false,
|
|
||||||
values: currentValues,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (onPresetSave) {
|
|
||||||
onPresetSave(newPreset);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPresetModalOpen(false);
|
|
||||||
setNewPresetName('');
|
|
||||||
setNewPresetDescription('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFilter = (filter: AnalyticsFilter) => {
|
|
||||||
const value = getFilterValue(filter);
|
|
||||||
const error = validationErrors[filter.id];
|
|
||||||
const isDisabled = disabled || loading;
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
disabled: isDisabled,
|
|
||||||
className: error ? 'border-red-300 focus:border-red-500' : '',
|
|
||||||
};
|
|
||||||
|
|
||||||
let filterInput: React.ReactNode;
|
|
||||||
|
|
||||||
switch (filter.type) {
|
|
||||||
case 'text':
|
|
||||||
filterInput = (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={filter.placeholder}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => updateFilterValue(filter.id, e.target.value)}
|
|
||||||
{...commonProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'number':
|
|
||||||
filterInput = (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={filter.placeholder}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => updateFilterValue(filter.id, parseFloat(e.target.value) || null)}
|
|
||||||
{...commonProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'select':
|
|
||||||
filterInput = (
|
|
||||||
<Select
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => updateFilterValue(filter.id, e.target.value)}
|
|
||||||
{...commonProps}
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar...</option>
|
|
||||||
{filter.options?.map((option) => (
|
|
||||||
<option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
disabled={option.disabled}
|
|
||||||
>
|
|
||||||
{option.icon} {option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'multiselect':
|
|
||||||
const selectedValues = Array.isArray(value) ? value : [];
|
|
||||||
filterInput = (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="max-h-32 overflow-y-auto border border-gray-300 rounded p-2">
|
|
||||||
{filter.options?.map((option) => (
|
|
||||||
<label key={option.value} className="flex items-center gap-2 p-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedValues.includes(option.value)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValues = e.target.checked
|
|
||||||
? [...selectedValues, option.value]
|
|
||||||
: selectedValues.filter(v => v !== option.value);
|
|
||||||
updateFilterValue(filter.id, newValues);
|
|
||||||
}}
|
|
||||||
disabled={isDisabled || option.disabled}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">
|
|
||||||
{option.icon} {option.label}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selectedValues.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedValues.map((val) => {
|
|
||||||
const option = filter.options?.find(opt => opt.value === val);
|
|
||||||
return (
|
|
||||||
<Badge key={val} className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{option?.label || val}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newValues = selectedValues.filter(v => v !== val);
|
|
||||||
updateFilterValue(filter.id, newValues);
|
|
||||||
}}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
filterInput = (
|
|
||||||
<Select
|
|
||||||
value={value === true ? 'true' : value === false ? 'false' : ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value === 'true' ? true : e.target.value === 'false' ? false : null;
|
|
||||||
updateFilterValue(filter.id, val);
|
|
||||||
}}
|
|
||||||
{...commonProps}
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar...</option>
|
|
||||||
<option value="true">Sí</option>
|
|
||||||
<option value="false">No</option>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'date':
|
|
||||||
filterInput = (
|
|
||||||
<DatePicker
|
|
||||||
value={value ? new Date(value) : null}
|
|
||||||
onChange={(date) => updateFilterValue(filter.id, date?.toISOString())}
|
|
||||||
placeholder={filter.placeholder}
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'range':
|
|
||||||
const rangeValue = Array.isArray(value) ? value : [null, null];
|
|
||||||
filterInput = (
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Mín"
|
|
||||||
value={rangeValue[0] || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newRange = [parseFloat(e.target.value) || null, rangeValue[1]];
|
|
||||||
updateFilterValue(filter.id, newRange);
|
|
||||||
}}
|
|
||||||
{...commonProps}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500">-</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Máx"
|
|
||||||
value={rangeValue[1] || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newRange = [rangeValue[0], parseFloat(e.target.value) || null];
|
|
||||||
updateFilterValue(filter.id, newRange);
|
|
||||||
}}
|
|
||||||
{...commonProps}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
filterInput = (
|
|
||||||
<div className="text-gray-500 text-sm">
|
|
||||||
Tipo de filtro no soportado: {filter.type}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={filter.id} className={compact ? 'space-y-1' : 'space-y-2'}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className={`font-medium text-gray-700 ${compact ? 'text-sm' : ''}`}>
|
|
||||||
<span className="mr-1">{FILTER_TYPE_ICONS[filter.type]}</span>
|
|
||||||
{filter.display_name}
|
|
||||||
{filter.required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{filter.help_text && (
|
|
||||||
<span className="text-xs text-gray-500" title={filter.help_text}>
|
|
||||||
ℹ️
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filterInput}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPresets = () => {
|
|
||||||
if (!showPresets || panel.presets.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-medium text-gray-700">Filtros guardados</h4>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsPresetModalOpen(true)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
💾 Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{panel.presets.map((preset) => (
|
|
||||||
<div key={preset.id} className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePresetSelect(preset.id)}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
className={panel.active_preset === preset.id ? 'bg-blue-50 border-blue-300' : ''}
|
|
||||||
>
|
|
||||||
{preset.icon && <span className="mr-1">{preset.icon}</span>}
|
|
||||||
{preset.name}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{onPresetDelete && !preset.is_public && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPresetDelete(preset.id)}
|
|
||||||
className="text-red-600 hover:text-red-800 p-1"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (panel.collapsible && isCollapsed) {
|
|
||||||
return (
|
|
||||||
<Card className={`p-3 ${className}`}>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCollapsed(false)}
|
|
||||||
className="flex items-center justify-between w-full text-left"
|
|
||||||
>
|
|
||||||
<h3 className="font-semibold text-gray-900">{panel.title}</h3>
|
|
||||||
<span className="text-gray-500">▼</span>
|
|
||||||
</button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridClass = panel.layout === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 gap-4' :
|
|
||||||
panel.layout === 'horizontal' ? 'grid grid-cols-1 lg:grid-cols-3 gap-4' :
|
|
||||||
'space-y-4';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className={`p-4 ${className}`}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold text-gray-900">{panel.title}</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{loading && (
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{panel.collapsible && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsCollapsed(true)}
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Presets */}
|
|
||||||
{renderPresets()}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className={gridClass}>
|
|
||||||
{panel.filters.map(filter => renderFilter(filter))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{appliedFilters.length} filtro{appliedFilters.length !== 1 ? 's' : ''} aplicado{appliedFilters.length !== 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{showReset && appliedFilters.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={resetFilters}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
Limpiar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
Aplicar filtros
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Save Preset Modal */}
|
|
||||||
{isPresetModalOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
|
||||||
<Card className="w-full max-w-md p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Guardar filtro</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Nombre del filtro *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={newPresetName}
|
|
||||||
onChange={(e) => setNewPresetName(e.target.value)}
|
|
||||||
placeholder="Ej: Ventas del último mes"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Descripción (opcional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={newPresetDescription}
|
|
||||||
onChange={(e) => setNewPresetDescription(e.target.value)}
|
|
||||||
placeholder="Descripción del filtro"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsPresetModalOpen(false);
|
|
||||||
setNewPresetName('');
|
|
||||||
setNewPresetDescription('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handlePresetSave}
|
|
||||||
disabled={!newPresetName.trim()}
|
|
||||||
>
|
|
||||||
Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FilterPanel;
|
|
||||||
@@ -1,651 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { Card, Button, Badge, Input, Select, Modal } from '../../ui';
|
|
||||||
import { Table } from '../../ui';
|
|
||||||
import type {
|
|
||||||
AnalyticsReport,
|
|
||||||
ReportsTableProps,
|
|
||||||
ExportFormat,
|
|
||||||
ReportType,
|
|
||||||
CustomAction
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const REPORT_TYPE_LABELS: Record<ReportType, string> = {
|
|
||||||
sales: 'Ventas',
|
|
||||||
production: 'Producción',
|
|
||||||
inventory: 'Inventario',
|
|
||||||
financial: 'Financiero',
|
|
||||||
customer: 'Clientes',
|
|
||||||
performance: 'Rendimiento',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
|
||||||
active: 'bg-green-100 text-green-800 border-green-200',
|
|
||||||
inactive: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
||||||
archived: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
|
||||||
active: 'Activo',
|
|
||||||
inactive: 'Inactivo',
|
|
||||||
archived: 'Archivado',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReportsTable: React.FC<ReportsTableProps> = ({
|
|
||||||
reports = [],
|
|
||||||
loading = false,
|
|
||||||
error,
|
|
||||||
selectedReports = [],
|
|
||||||
onSelectionChange,
|
|
||||||
onReportClick,
|
|
||||||
onEditReport,
|
|
||||||
onDeleteReport,
|
|
||||||
onScheduleReport,
|
|
||||||
onShareReport,
|
|
||||||
onExportReport,
|
|
||||||
filters = [],
|
|
||||||
onFiltersChange,
|
|
||||||
sortable = true,
|
|
||||||
pagination,
|
|
||||||
bulkActions = false,
|
|
||||||
customActions = [],
|
|
||||||
tableConfig,
|
|
||||||
}) => {
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [typeFilter, setTypeFilter] = useState<ReportType | 'all'>('all');
|
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'archived'>('all');
|
|
||||||
const [sortField, setSortField] = useState<keyof AnalyticsReport>('updated_at');
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const [reportToDelete, setReportToDelete] = useState<string | null>(null);
|
|
||||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
|
||||||
const [reportToSchedule, setReportToSchedule] = useState<AnalyticsReport | null>(null);
|
|
||||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
|
||||||
const [reportToShare, setReportToShare] = useState<AnalyticsReport | null>(null);
|
|
||||||
|
|
||||||
const filteredAndSortedReports = useMemo(() => {
|
|
||||||
let filtered = reports.filter(report => {
|
|
||||||
const matchesSearch = !searchTerm ||
|
|
||||||
report.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
report.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
report.category.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesType = typeFilter === 'all' || report.type === typeFilter;
|
|
||||||
const matchesStatus = statusFilter === 'all' || report.status === statusFilter;
|
|
||||||
|
|
||||||
return matchesSearch && matchesType && matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sortable && sortField) {
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
const aVal = a[sortField];
|
|
||||||
const bVal = b[sortField];
|
|
||||||
|
|
||||||
if (aVal === null || aVal === undefined) return 1;
|
|
||||||
if (bVal === null || bVal === undefined) return -1;
|
|
||||||
|
|
||||||
let comparison = 0;
|
|
||||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
||||||
comparison = aVal.localeCompare(bVal);
|
|
||||||
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
||||||
comparison = aVal - bVal;
|
|
||||||
} else {
|
|
||||||
comparison = String(aVal).localeCompare(String(bVal));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortDirection === 'asc' ? comparison : -comparison;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [reports, searchTerm, typeFilter, statusFilter, sortField, sortDirection, sortable]);
|
|
||||||
|
|
||||||
const handleSort = (field: keyof AnalyticsReport) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
|
||||||
if (onSelectionChange) {
|
|
||||||
onSelectionChange(checked ? filteredAndSortedReports.map(r => r.id) : []);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectReport = (reportId: string, checked: boolean) => {
|
|
||||||
if (onSelectionChange) {
|
|
||||||
if (checked) {
|
|
||||||
onSelectionChange([...selectedReports, reportId]);
|
|
||||||
} else {
|
|
||||||
onSelectionChange(selectedReports.filter(id => id !== reportId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (reportToDelete && onDeleteReport) {
|
|
||||||
onDeleteReport(reportToDelete);
|
|
||||||
}
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
setReportToDelete(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDelete = () => {
|
|
||||||
if (selectedReports.length > 0 && onDeleteReport) {
|
|
||||||
selectedReports.forEach(id => onDeleteReport(id));
|
|
||||||
if (onSelectionChange) {
|
|
||||||
onSelectionChange([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderActionButton = (report: AnalyticsReport, action: string) => {
|
|
||||||
switch (action) {
|
|
||||||
case 'view':
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onReportClick?.(report)}
|
|
||||||
title="Ver reporte"
|
|
||||||
>
|
|
||||||
👁️
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case 'edit':
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onEditReport?.(report)}
|
|
||||||
title="Editar reporte"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case 'schedule':
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setReportToSchedule(report);
|
|
||||||
setIsScheduleModalOpen(true);
|
|
||||||
}}
|
|
||||||
title="Programar reporte"
|
|
||||||
>
|
|
||||||
📅
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case 'share':
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setReportToShare(report);
|
|
||||||
setIsShareModalOpen(true);
|
|
||||||
}}
|
|
||||||
title="Compartir reporte"
|
|
||||||
>
|
|
||||||
📤
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
case 'export':
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onExportReport?.(report, 'pdf')}
|
|
||||||
title="Exportar como PDF"
|
|
||||||
>
|
|
||||||
📄
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onExportReport?.(report, 'excel')}
|
|
||||||
title="Exportar como Excel"
|
|
||||||
>
|
|
||||||
📊
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'delete':
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setReportToDelete(report.id);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
title="Eliminar reporte"
|
|
||||||
className="text-red-600 hover:text-red-800"
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableColumns = [
|
|
||||||
{
|
|
||||||
key: 'select',
|
|
||||||
title: (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedReports.length === filteredAndSortedReports.length && filteredAndSortedReports.length > 0}
|
|
||||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedReports.includes(report.id)}
|
|
||||||
onChange={(e) => handleSelectReport(report.id, e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
width: 50,
|
|
||||||
visible: bulkActions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
title: 'Nombre',
|
|
||||||
sortable: true,
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => onReportClick?.(report)}
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-800 text-left"
|
|
||||||
>
|
|
||||||
{report.name}
|
|
||||||
</button>
|
|
||||||
{report.description && (
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{report.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
minWidth: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
title: 'Tipo',
|
|
||||||
sortable: true,
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
{REPORT_TYPE_LABELS[report.type]}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'category',
|
|
||||||
title: 'Categoría',
|
|
||||||
sortable: true,
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: 'Estado',
|
|
||||||
sortable: true,
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
<Badge className={STATUS_COLORS[report.status]}>
|
|
||||||
{STATUS_LABELS[report.status]}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'last_run',
|
|
||||||
title: 'Última ejecución',
|
|
||||||
sortable: true,
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
report.last_run
|
|
||||||
? new Date(report.last_run).toLocaleDateString('es-ES')
|
|
||||||
: '-'
|
|
||||||
),
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'next_run',
|
|
||||||
title: 'Próxima ejecución',
|
|
||||||
sortable: true,
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
report.next_run
|
|
||||||
? new Date(report.next_run).toLocaleDateString('es-ES')
|
|
||||||
: '-'
|
|
||||||
),
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'updated_at',
|
|
||||||
title: 'Actualizado',
|
|
||||||
sortable: true,
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
new Date(report.updated_at).toLocaleDateString('es-ES')
|
|
||||||
),
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
title: 'Acciones',
|
|
||||||
render: (report: AnalyticsReport) => (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{renderActionButton(report, 'view')}
|
|
||||||
{renderActionButton(report, 'edit')}
|
|
||||||
{renderActionButton(report, 'schedule')}
|
|
||||||
{renderActionButton(report, 'export')}
|
|
||||||
{renderActionButton(report, 'delete')}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
width: 200,
|
|
||||||
},
|
|
||||||
].filter(col => col.visible !== false);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-red-600 font-medium">Error al cargar los reportes</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{error}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
{/* Filters and Actions */}
|
|
||||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
|
||||||
{/* Search */}
|
|
||||||
{(tableConfig?.showSearch !== false) && (
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Buscar reportes..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
value={typeFilter}
|
|
||||||
onChange={(e) => setTypeFilter(e.target.value as ReportType | 'all')}
|
|
||||||
className="w-40"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los tipos</option>
|
|
||||||
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
|
||||||
className="w-32"
|
|
||||||
>
|
|
||||||
<option value="all">Todos</option>
|
|
||||||
<option value="active">Activos</option>
|
|
||||||
<option value="inactive">Inactivos</option>
|
|
||||||
<option value="archived">Archivados</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
|
||||||
{bulkActions && selectedReports.length > 0 && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleBulkDelete}
|
|
||||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
Eliminar seleccionados ({selectedReports.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table
|
|
||||||
columns={tableColumns}
|
|
||||||
data={filteredAndSortedReports}
|
|
||||||
loading={loading}
|
|
||||||
sortable={sortable}
|
|
||||||
onSort={handleSort}
|
|
||||||
currentSort={{ field: sortField as string, direction: sortDirection }}
|
|
||||||
emptyMessage="No se encontraron reportes"
|
|
||||||
className="min-w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{pagination && (
|
|
||||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Mostrando {Math.min(pagination.pageSize * (pagination.current - 1) + 1, pagination.total)} - {Math.min(pagination.pageSize * pagination.current, pagination.total)} de {pagination.total} reportes
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => pagination.onChange(pagination.current - 1, pagination.pageSize)}
|
|
||||||
disabled={pagination.current <= 1}
|
|
||||||
>
|
|
||||||
Anterior
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Página {pagination.current} de {Math.ceil(pagination.total / pagination.pageSize)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => pagination.onChange(pagination.current + 1, pagination.pageSize)}
|
|
||||||
disabled={pagination.current >= Math.ceil(pagination.total / pagination.pageSize)}
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isDeleteModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
setReportToDelete(null);
|
|
||||||
}}
|
|
||||||
title="Confirmar eliminación"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>¿Estás seguro de que deseas eliminar este reporte? Esta acción no se puede deshacer.</p>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
setReportToDelete(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={handleDeleteConfirm}
|
|
||||||
>
|
|
||||||
Eliminar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Schedule Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isScheduleModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsScheduleModalOpen(false);
|
|
||||||
setReportToSchedule(null);
|
|
||||||
}}
|
|
||||||
title="Programar reporte"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>Configurar programación para: <strong>{reportToSchedule?.name}</strong></p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Frecuencia
|
|
||||||
</label>
|
|
||||||
<Select>
|
|
||||||
<option value="daily">Diario</option>
|
|
||||||
<option value="weekly">Semanal</option>
|
|
||||||
<option value="monthly">Mensual</option>
|
|
||||||
<option value="quarterly">Trimestral</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Hora de ejecución
|
|
||||||
</label>
|
|
||||||
<Input type="time" defaultValue="09:00" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Destinatarios (emails separados por coma)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="ejemplo@empresa.com, otro@empresa.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsScheduleModalOpen(false);
|
|
||||||
setReportToSchedule(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
if (reportToSchedule && onScheduleReport) {
|
|
||||||
onScheduleReport(reportToSchedule);
|
|
||||||
}
|
|
||||||
setIsScheduleModalOpen(false);
|
|
||||||
setReportToSchedule(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Programar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Share Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isShareModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsShareModalOpen(false);
|
|
||||||
setReportToShare(null);
|
|
||||||
}}
|
|
||||||
title="Compartir reporte"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>Compartir: <strong>{reportToShare?.name}</strong></p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Generar enlace de acceso
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={`${window.location.origin}/reports/${reportToShare?.id}/shared`}
|
|
||||||
readOnly
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(`${window.location.origin}/reports/${reportToShare?.id}/shared`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copiar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" id="expire-link" />
|
|
||||||
<label htmlFor="expire-link" className="text-sm">
|
|
||||||
El enlace expira en 7 días
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" id="password-protect" />
|
|
||||||
<label htmlFor="password-protect" className="text-sm">
|
|
||||||
Proteger con contraseña
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsShareModalOpen(false);
|
|
||||||
setReportToShare(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
if (reportToShare && onShareReport) {
|
|
||||||
onShareReport(reportToShare);
|
|
||||||
}
|
|
||||||
setIsShareModalOpen(false);
|
|
||||||
setReportToShare(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generar enlace
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ReportsTable;
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
import React, { forwardRef, HTMLAttributes, ReactNode, useState } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Card, CardHeader, CardBody, CardFooter, Button, Badge } from '../../ui';
|
|
||||||
|
|
||||||
export interface DashboardCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
||||||
// Card variants for different dashboard contexts
|
|
||||||
variant?: 'metric' | 'chart' | 'list' | 'activity' | 'status' | 'action';
|
|
||||||
|
|
||||||
// Header props
|
|
||||||
title?: ReactNode;
|
|
||||||
subtitle?: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
headerActions?: ReactNode;
|
|
||||||
|
|
||||||
// Loading and state management
|
|
||||||
isLoading?: boolean;
|
|
||||||
hasError?: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
isEmpty?: boolean;
|
|
||||||
emptyMessage?: string;
|
|
||||||
|
|
||||||
// Footer props
|
|
||||||
footerActions?: ReactNode;
|
|
||||||
footerText?: ReactNode;
|
|
||||||
|
|
||||||
// Badge/notification support
|
|
||||||
badge?: string | number;
|
|
||||||
badgeVariant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
// Interactive behavior
|
|
||||||
interactive?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
|
|
||||||
// Layout customization
|
|
||||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
headerPadding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
bodyPadding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
// Accessibility
|
|
||||||
'aria-label'?: string;
|
|
||||||
'aria-describedby'?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardCard = forwardRef<HTMLDivElement, DashboardCardProps>(({
|
|
||||||
variant = 'metric',
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
icon,
|
|
||||||
headerActions,
|
|
||||||
isLoading = false,
|
|
||||||
hasError = false,
|
|
||||||
errorMessage = 'Ha ocurrido un error',
|
|
||||||
isEmpty = false,
|
|
||||||
emptyMessage = 'No hay datos disponibles',
|
|
||||||
footerActions,
|
|
||||||
footerText,
|
|
||||||
badge,
|
|
||||||
badgeVariant = 'primary',
|
|
||||||
interactive = false,
|
|
||||||
onClick,
|
|
||||||
onRefresh,
|
|
||||||
padding = 'md',
|
|
||||||
headerPadding,
|
|
||||||
bodyPadding,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
'aria-label': ariaLabel,
|
|
||||||
'aria-describedby': ariaDescribedby,
|
|
||||||
...props
|
|
||||||
}, ref) => {
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
if (onRefresh && !isRefreshing) {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
await onRefresh();
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
metric: 'bg-gradient-to-br from-white to-blue-50 border-blue-200 hover:border-blue-300',
|
|
||||||
chart: 'bg-white border-gray-200 hover:border-gray-300',
|
|
||||||
list: 'bg-white border-gray-200 hover:border-gray-300',
|
|
||||||
activity: 'bg-gradient-to-br from-white to-green-50 border-green-200 hover:border-green-300',
|
|
||||||
status: 'bg-gradient-to-br from-white to-purple-50 border-purple-200 hover:border-purple-300',
|
|
||||||
action: 'bg-gradient-to-br from-white to-amber-50 border-amber-200 hover:border-amber-300 hover:shadow-lg'
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardClasses = clsx(
|
|
||||||
variantStyles[variant],
|
|
||||||
'transition-all duration-300',
|
|
||||||
{
|
|
||||||
'cursor-pointer transform hover:-translate-y-1': interactive || onClick,
|
|
||||||
'opacity-50': isLoading,
|
|
||||||
'border-red-300 bg-red-50': hasError,
|
|
||||||
},
|
|
||||||
className
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasHeader = title || subtitle || icon || headerActions || badge || onRefresh;
|
|
||||||
|
|
||||||
const renderSkeletonContent = () => {
|
|
||||||
switch (variant) {
|
|
||||||
case 'metric':
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="h-8 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div className="h-12 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'chart':
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="h-6 bg-gray-200 rounded animate-pulse w-1/2" />
|
|
||||||
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'list':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center space-x-3">
|
|
||||||
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="h-4 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-2/3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <div className="h-24 bg-gray-200 rounded animate-pulse" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderErrorContent = () => (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-red-500 mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 mx-auto"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 mb-4">{errorMessage}</p>
|
|
||||||
{onRefresh && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
isLoading={isRefreshing}
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderEmptyContent = () => (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-gray-400 mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 mx-auto"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500">{emptyMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
ref={ref}
|
|
||||||
className={cardClasses}
|
|
||||||
padding={padding}
|
|
||||||
interactive={interactive}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-describedby={ariaDescribedby}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{hasHeader && (
|
|
||||||
<CardHeader
|
|
||||||
padding={headerPadding}
|
|
||||||
className="flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
|
||||||
{icon && (
|
|
||||||
<div className="flex-shrink-0 text-gray-600">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{title && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{badge && (
|
|
||||||
<Badge
|
|
||||||
variant={badgeVariant}
|
|
||||||
size="sm"
|
|
||||||
shape="pill"
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subtitle && (
|
|
||||||
<p className="text-sm text-gray-600 mt-1 truncate">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
|
||||||
{onRefresh && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
isLoading={isRefreshing}
|
|
||||||
aria-label="Actualizar datos"
|
|
||||||
className="text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{headerActions}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardBody padding={bodyPadding}>
|
|
||||||
{isLoading
|
|
||||||
? renderSkeletonContent()
|
|
||||||
: hasError
|
|
||||||
? renderErrorContent()
|
|
||||||
: isEmpty
|
|
||||||
? renderEmptyContent()
|
|
||||||
: children}
|
|
||||||
</CardBody>
|
|
||||||
|
|
||||||
{(footerActions || footerText) && (
|
|
||||||
<CardFooter className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{footerText}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{footerActions}
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
DashboardCard.displayName = 'DashboardCard';
|
|
||||||
|
|
||||||
export default DashboardCard;
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
TrendingUp,
|
|
||||||
Package,
|
|
||||||
AlertCircle,
|
|
||||||
Cloud,
|
|
||||||
Clock,
|
|
||||||
DollarSign,
|
|
||||||
Users,
|
|
||||||
ShoppingCart
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card } from '../../ui';
|
|
||||||
import { Badge } from '../../ui';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { dashboardService } from '../../../services/api/dashboard.service';
|
|
||||||
import { ForecastChart } from '../forecasting/ForecastChart';
|
|
||||||
import { SalesChart } from '../sales/SalesChart';
|
|
||||||
import { AIInsightCard } from '../ai/AIInsightCard';
|
|
||||||
import { ProductionStatusCard } from '../production/ProductionStatusCard';
|
|
||||||
import { AlertsFeed } from '../alerts/AlertsFeed';
|
|
||||||
import { useBakeryStore } from '../../../stores/bakery.store';
|
|
||||||
|
|
||||||
export const DashboardGrid: React.FC = () => {
|
|
||||||
const { currentTenant, bakeryType } = useBakeryStore();
|
|
||||||
|
|
||||||
const { data: dashboardData, isLoading } = useQuery({
|
|
||||||
queryKey: ['dashboard', currentTenant?.id],
|
|
||||||
queryFn: () => dashboardService.getDashboardData(currentTenant!.id),
|
|
||||||
enabled: !!currentTenant,
|
|
||||||
refetchInterval: 60000, // Refresh every minute
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <DashboardSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
>
|
|
||||||
{/* KPI Cards Row */}
|
|
||||||
<motion.div variants={itemVariants}>
|
|
||||||
<KPICard
|
|
||||||
title="Ventas Hoy"
|
|
||||||
value={`€${dashboardData?.sales_today || 0}`}
|
|
||||||
change={dashboardData?.sales_change || 0}
|
|
||||||
icon={<DollarSign className="w-5 h-5" />}
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants}>
|
|
||||||
<KPICard
|
|
||||||
title="Productos Vendidos"
|
|
||||||
value={dashboardData?.products_sold || 0}
|
|
||||||
change={dashboardData?.products_change || 0}
|
|
||||||
icon={<Package className="w-5 h-5" />}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants}>
|
|
||||||
<KPICard
|
|
||||||
title="Alertas Activas"
|
|
||||||
value={dashboardData?.active_alerts || 0}
|
|
||||||
urgent={dashboardData?.urgent_alerts || 0}
|
|
||||||
icon={<AlertCircle className="w-5 h-5" />}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants}>
|
|
||||||
<WeatherCard
|
|
||||||
temperature={dashboardData?.weather?.temperature}
|
|
||||||
condition={dashboardData?.weather?.condition}
|
|
||||||
impact={dashboardData?.weather?.sales_impact}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Charts Row */}
|
|
||||||
<motion.div variants={itemVariants} className="col-span-full lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title>Predicción de Demanda - 7 Días</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content className="h-80">
|
|
||||||
<ForecastChart
|
|
||||||
data={dashboardData?.forecast_data || []}
|
|
||||||
confidence={dashboardData?.confidence_interval}
|
|
||||||
interactive
|
|
||||||
/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants} className="col-span-full lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title>Análisis de Ventas</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content className="h-80">
|
|
||||||
<SalesChart
|
|
||||||
data={dashboardData?.sales_data || []}
|
|
||||||
comparison="previous_week"
|
|
||||||
breakdown="category"
|
|
||||||
/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Status Cards */}
|
|
||||||
{bakeryType === 'individual' && (
|
|
||||||
<motion.div variants={itemVariants} className="col-span-full xl:col-span-2">
|
|
||||||
<ProductionStatusCard
|
|
||||||
batches={dashboardData?.production_batches || []}
|
|
||||||
efficiency={dashboardData?.production_efficiency}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Insights */}
|
|
||||||
<motion.div variants={itemVariants} className="col-span-full xl:col-span-1">
|
|
||||||
<AIInsightCard
|
|
||||||
insights={dashboardData?.ai_insights || []}
|
|
||||||
onActionClick={(action) => console.log('AI Action:', action)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Real-time Alerts Feed */}
|
|
||||||
<motion.div variants={itemVariants} className="col-span-full xl:col-span-1">
|
|
||||||
<AlertsFeed />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// KPI Card Component
|
|
||||||
interface KPICardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
change?: number;
|
|
||||||
urgent?: number;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
color: 'green' | 'blue' | 'orange' | 'red';
|
|
||||||
}
|
|
||||||
|
|
||||||
const KPICard: React.FC<KPICardProps> = ({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
change,
|
|
||||||
urgent,
|
|
||||||
icon,
|
|
||||||
color
|
|
||||||
}) => {
|
|
||||||
const colorClasses = {
|
|
||||||
green: 'bg-green-50 text-green-600 border-green-200',
|
|
||||||
blue: 'bg-blue-50 text-blue-600 border-blue-200',
|
|
||||||
orange: 'bg-orange-50 text-orange-600 border-orange-200',
|
|
||||||
red: 'bg-red-50 text-red-600 border-red-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={`relative overflow-hidden ${colorClasses[color]} border-2`}>
|
|
||||||
<Card.Content className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className={`p-2 rounded-lg ${color === 'green' ? 'bg-green-100' : color === 'blue' ? 'bg-blue-100' : color === 'orange' ? 'bg-orange-100' : 'bg-red-100'}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
{urgent !== undefined && urgent > 0 && (
|
|
||||||
<Badge variant="destructive" className="animate-pulse">
|
|
||||||
{urgent} Urgente
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
|
||||||
|
|
||||||
{change !== undefined && (
|
|
||||||
<div className="flex items-center space-x-1 text-sm">
|
|
||||||
<TrendingUp className={`w-4 h-4 ${change >= 0 ? 'text-green-500' : 'text-red-500 rotate-180'}`} />
|
|
||||||
<span className={change >= 0 ? 'text-green-600' : 'text-red-600'}>
|
|
||||||
{Math.abs(change)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-500">vs ayer</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Weather Impact Card
|
|
||||||
const WeatherCard: React.FC<{
|
|
||||||
temperature?: number;
|
|
||||||
condition?: string;
|
|
||||||
impact?: string;
|
|
||||||
}> = ({ temperature, condition, impact }) => {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-br from-blue-50 to-cyan-50 border-blue-200">
|
|
||||||
<Card.Content className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<Cloud className="w-8 h-8 text-blue-500" />
|
|
||||||
<Badge variant={impact === 'positive' ? 'success' : impact === 'negative' ? 'destructive' : 'secondary'}>
|
|
||||||
{impact === 'positive' ? '↑' : impact === 'negative' ? '↓' : '='} Impacto
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-2xl font-bold">{temperature}°C</p>
|
|
||||||
<p className="text-sm text-gray-600">{condition}</p>
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DashboardSkeleton: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
|
||||||
{[...Array(8)].map((_, i) => (
|
|
||||||
<Card key={i} className="animate-pulse">
|
|
||||||
<Card.Content className="p-6">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
|
|
||||||
<div className="h-12 bg-gray-300 rounded w-1/2" />
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
import React, { KeyboardEvent, useCallback, useMemo } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Button, Badge } from '../../ui';
|
|
||||||
|
|
||||||
export interface QuickAction {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
href?: string;
|
|
||||||
|
|
||||||
// Badge/notification support
|
|
||||||
badge?: string | number;
|
|
||||||
badgeVariant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
// Access control
|
|
||||||
permissions?: string[];
|
|
||||||
requiredRole?: string;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
disabledReason?: string;
|
|
||||||
|
|
||||||
// Styling
|
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger';
|
|
||||||
color?: string;
|
|
||||||
backgroundGradient?: string;
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
shortcut?: string;
|
|
||||||
|
|
||||||
// Priority for ordering
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuickActionsProps {
|
|
||||||
actions: QuickAction[];
|
|
||||||
|
|
||||||
// Layout configuration
|
|
||||||
columns?: 2 | 3 | 4 | 5 | 6;
|
|
||||||
gap?: 'sm' | 'md' | 'lg';
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
// Filtering and user context
|
|
||||||
userRole?: string;
|
|
||||||
userPermissions?: string[];
|
|
||||||
showDisabled?: boolean;
|
|
||||||
maxActions?: number;
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
onActionClick?: (action: QuickAction) => void;
|
|
||||||
onActionHover?: (action: QuickAction) => void;
|
|
||||||
|
|
||||||
// Accessibility
|
|
||||||
'aria-label'?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predefined bakery actions with Spanish context
|
|
||||||
export const BAKERY_QUICK_ACTIONS: QuickAction[] = [
|
|
||||||
{
|
|
||||||
id: 'new-order',
|
|
||||||
title: 'Nuevo Pedido',
|
|
||||||
description: 'Crear un nuevo pedido de cliente',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Nuevo pedido'),
|
|
||||||
variant: 'primary',
|
|
||||||
backgroundGradient: 'from-blue-500 to-blue-600',
|
|
||||||
priority: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'add-product',
|
|
||||||
title: 'Agregar Producto',
|
|
||||||
description: 'Añadir nuevo producto al inventario',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Agregar producto'),
|
|
||||||
variant: 'success',
|
|
||||||
backgroundGradient: 'from-green-500 to-green-600',
|
|
||||||
priority: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'view-inventory',
|
|
||||||
title: 'Ver Inventario',
|
|
||||||
description: 'Consultar stock y productos',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Ver inventario'),
|
|
||||||
variant: 'outline',
|
|
||||||
backgroundGradient: 'from-purple-500 to-purple-600',
|
|
||||||
priority: 3,
|
|
||||||
badge: '5',
|
|
||||||
badgeVariant: 'warning'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'production-batch',
|
|
||||||
title: 'Nueva Producción',
|
|
||||||
description: 'Programar lote de producción',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Nueva producción'),
|
|
||||||
variant: 'warning',
|
|
||||||
backgroundGradient: 'from-orange-500 to-orange-600',
|
|
||||||
priority: 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sales-report',
|
|
||||||
title: 'Reporte Ventas',
|
|
||||||
description: 'Ver análisis de ventas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Reporte ventas'),
|
|
||||||
variant: 'secondary',
|
|
||||||
backgroundGradient: 'from-indigo-500 to-indigo-600',
|
|
||||||
priority: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'manage-suppliers',
|
|
||||||
title: 'Proveedores',
|
|
||||||
description: 'Gestionar proveedores',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Proveedores'),
|
|
||||||
variant: 'outline',
|
|
||||||
backgroundGradient: 'from-teal-500 to-teal-600',
|
|
||||||
priority: 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pos-system',
|
|
||||||
title: 'Sistema POS',
|
|
||||||
description: 'Punto de venta',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Sistema POS'),
|
|
||||||
variant: 'primary',
|
|
||||||
backgroundGradient: 'from-emerald-500 to-emerald-600',
|
|
||||||
priority: 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'quality-control',
|
|
||||||
title: 'Control Calidad',
|
|
||||||
description: 'Verificación de calidad',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
onClick: () => console.log('Control calidad'),
|
|
||||||
variant: 'success',
|
|
||||||
backgroundGradient: 'from-lime-500 to-lime-600',
|
|
||||||
priority: 8,
|
|
||||||
requiredRole: 'quality_manager'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const QuickActions: React.FC<QuickActionsProps> = ({
|
|
||||||
actions,
|
|
||||||
columns = 3,
|
|
||||||
gap = 'md',
|
|
||||||
size = 'md',
|
|
||||||
userRole,
|
|
||||||
userPermissions = [],
|
|
||||||
showDisabled = false,
|
|
||||||
maxActions,
|
|
||||||
onActionClick,
|
|
||||||
onActionHover,
|
|
||||||
'aria-label': ariaLabel = 'Acciones rápidas',
|
|
||||||
className
|
|
||||||
}) => {
|
|
||||||
// Filter and sort actions
|
|
||||||
const visibleActions = useMemo(() => {
|
|
||||||
let filteredActions = actions.filter(action => {
|
|
||||||
// Role-based filtering
|
|
||||||
if (action.requiredRole && userRole !== action.requiredRole) {
|
|
||||||
return showDisabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission-based filtering
|
|
||||||
if (action.permissions && action.permissions.length > 0) {
|
|
||||||
const hasPermission = action.permissions.some(perm =>
|
|
||||||
userPermissions.includes(perm)
|
|
||||||
);
|
|
||||||
if (!hasPermission) {
|
|
||||||
return showDisabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by priority
|
|
||||||
filteredActions.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
|
||||||
|
|
||||||
// Limit actions if specified
|
|
||||||
if (maxActions) {
|
|
||||||
filteredActions = filteredActions.slice(0, maxActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredActions;
|
|
||||||
}, [actions, userRole, userPermissions, showDisabled, maxActions]);
|
|
||||||
|
|
||||||
const handleActionClick = useCallback((action: QuickAction) => {
|
|
||||||
if (action.isDisabled) return;
|
|
||||||
|
|
||||||
onActionClick?.(action);
|
|
||||||
action.onClick();
|
|
||||||
}, [onActionClick]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>, action: QuickAction) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleActionClick(action);
|
|
||||||
}
|
|
||||||
}, [handleActionClick]);
|
|
||||||
|
|
||||||
const gridClasses = {
|
|
||||||
2: 'grid-cols-2 sm:grid-cols-2',
|
|
||||||
3: 'grid-cols-2 sm:grid-cols-3',
|
|
||||||
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
|
||||||
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
|
||||||
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6'
|
|
||||||
};
|
|
||||||
|
|
||||||
const gapClasses = {
|
|
||||||
sm: 'gap-2',
|
|
||||||
md: 'gap-4',
|
|
||||||
lg: 'gap-6'
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'p-3 min-h-[80px]',
|
|
||||||
md: 'p-4 min-h-[100px]',
|
|
||||||
lg: 'p-6 min-h-[120px]'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (visibleActions.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<p>No hay acciones disponibles</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'grid',
|
|
||||||
gridClasses[columns],
|
|
||||||
gapClasses[gap],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
role="grid"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
{visibleActions.map((action) => {
|
|
||||||
const isDisabled = action.isDisabled ||
|
|
||||||
(action.requiredRole && userRole !== action.requiredRole) ||
|
|
||||||
(action.permissions && !action.permissions.some(perm => userPermissions.includes(perm)));
|
|
||||||
|
|
||||||
const buttonClasses = clsx(
|
|
||||||
'relative group transition-all duration-200',
|
|
||||||
'border border-gray-200 rounded-xl',
|
|
||||||
'flex flex-col items-center justify-center text-center',
|
|
||||||
'hover:shadow-lg hover:-translate-y-1',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
|
||||||
sizeClasses[size],
|
|
||||||
{
|
|
||||||
'bg-white hover:bg-gray-50': !action.backgroundGradient,
|
|
||||||
'bg-gradient-to-br text-white hover:opacity-90': action.backgroundGradient,
|
|
||||||
'opacity-50 cursor-not-allowed hover:transform-none hover:shadow-none': isDisabled,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const gradientStyle = action.backgroundGradient ? {
|
|
||||||
background: `linear-gradient(135deg, var(--tw-gradient-stops))`,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
className={buttonClasses}
|
|
||||||
onClick={() => handleActionClick(action)}
|
|
||||||
onKeyDown={(e) => handleKeyDown(e, action)}
|
|
||||||
onMouseEnter={() => onActionHover?.(action)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
title={isDisabled ? action.disabledReason || 'Acción no disponible' : action.description}
|
|
||||||
aria-label={`${action.title}. ${action.description || ''}`}
|
|
||||||
style={gradientStyle}
|
|
||||||
>
|
|
||||||
{/* Badge */}
|
|
||||||
{action.badge && (
|
|
||||||
<Badge
|
|
||||||
variant={action.badgeVariant}
|
|
||||||
size="sm"
|
|
||||||
className="absolute -top-2 -right-2 z-10"
|
|
||||||
>
|
|
||||||
{action.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<div className={clsx(
|
|
||||||
'mb-2 transition-transform duration-200',
|
|
||||||
'group-hover:scale-110',
|
|
||||||
{
|
|
||||||
'text-gray-600': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.icon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<span className={clsx(
|
|
||||||
'font-medium text-sm leading-tight',
|
|
||||||
{
|
|
||||||
'text-gray-900': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{action.description && (
|
|
||||||
<span className={clsx(
|
|
||||||
'text-xs mt-1 opacity-75 leading-tight',
|
|
||||||
{
|
|
||||||
'text-gray-600': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Keyboard shortcut */}
|
|
||||||
{action.shortcut && (
|
|
||||||
<div className={clsx(
|
|
||||||
'absolute bottom-1 right-1 text-xs opacity-50',
|
|
||||||
{
|
|
||||||
'text-gray-500': !action.backgroundGradient,
|
|
||||||
'text-white': action.backgroundGradient,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{action.shortcut}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hover effect overlay */}
|
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 rounded-xl transition-all duration-200" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
QuickActions.displayName = 'QuickActions';
|
|
||||||
|
|
||||||
export default QuickActions;
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
import React, { useMemo, useState, useCallback } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Avatar, Badge, Button } from '../../ui';
|
|
||||||
|
|
||||||
export interface ActivityUser {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityItem {
|
|
||||||
id: string;
|
|
||||||
type: ActivityType;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
timestamp: string;
|
|
||||||
user?: ActivityUser;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
status?: ActivityStatus;
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
// Navigation support
|
|
||||||
href?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
|
|
||||||
// Visual styling
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
color?: string;
|
|
||||||
priority?: ActivityPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActivityType {
|
|
||||||
ORDER = 'order',
|
|
||||||
PRODUCTION = 'production',
|
|
||||||
INVENTORY = 'inventory',
|
|
||||||
SALES = 'sales',
|
|
||||||
USER = 'user',
|
|
||||||
SYSTEM = 'system',
|
|
||||||
QUALITY = 'quality',
|
|
||||||
SUPPLIER = 'supplier',
|
|
||||||
FINANCE = 'finance',
|
|
||||||
ALERT = 'alert'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActivityStatus {
|
|
||||||
SUCCESS = 'success',
|
|
||||||
WARNING = 'warning',
|
|
||||||
ERROR = 'error',
|
|
||||||
INFO = 'info',
|
|
||||||
PENDING = 'pending',
|
|
||||||
IN_PROGRESS = 'in_progress',
|
|
||||||
CANCELLED = 'cancelled'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActivityPriority {
|
|
||||||
LOW = 'low',
|
|
||||||
MEDIUM = 'medium',
|
|
||||||
HIGH = 'high',
|
|
||||||
URGENT = 'urgent'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentActivityProps {
|
|
||||||
activities: ActivityItem[];
|
|
||||||
|
|
||||||
// Display configuration
|
|
||||||
maxItems?: number;
|
|
||||||
showTimestamp?: boolean;
|
|
||||||
showUserAvatar?: boolean;
|
|
||||||
showTypeIcons?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
|
|
||||||
// Filtering
|
|
||||||
allowFiltering?: boolean;
|
|
||||||
filterTypes?: ActivityType[];
|
|
||||||
defaultFilter?: ActivityType | 'all';
|
|
||||||
|
|
||||||
// Pagination and loading
|
|
||||||
hasMore?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
onActivityClick?: (activity: ActivityItem) => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
|
|
||||||
// Accessibility and styling
|
|
||||||
className?: string;
|
|
||||||
'aria-label'?: string;
|
|
||||||
emptyMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spanish activity type labels and icons
|
|
||||||
const ACTIVITY_CONFIG = {
|
|
||||||
[ActivityType.ORDER]: {
|
|
||||||
label: 'Pedidos',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'blue',
|
|
||||||
bgColor: 'bg-blue-100',
|
|
||||||
textColor: 'text-blue-600',
|
|
||||||
borderColor: 'border-blue-200'
|
|
||||||
},
|
|
||||||
[ActivityType.PRODUCTION]: {
|
|
||||||
label: 'Producción',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'orange',
|
|
||||||
bgColor: 'bg-orange-100',
|
|
||||||
textColor: 'text-orange-600',
|
|
||||||
borderColor: 'border-orange-200'
|
|
||||||
},
|
|
||||||
[ActivityType.INVENTORY]: {
|
|
||||||
label: 'Inventario',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'purple',
|
|
||||||
bgColor: 'bg-purple-100',
|
|
||||||
textColor: 'text-purple-600',
|
|
||||||
borderColor: 'border-purple-200'
|
|
||||||
},
|
|
||||||
[ActivityType.SALES]: {
|
|
||||||
label: 'Ventas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-green-100',
|
|
||||||
textColor: 'text-green-600',
|
|
||||||
borderColor: 'border-green-200'
|
|
||||||
},
|
|
||||||
[ActivityType.USER]: {
|
|
||||||
label: 'Usuarios',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'indigo',
|
|
||||||
bgColor: 'bg-indigo-100',
|
|
||||||
textColor: 'text-indigo-600',
|
|
||||||
borderColor: 'border-indigo-200'
|
|
||||||
},
|
|
||||||
[ActivityType.SYSTEM]: {
|
|
||||||
label: 'Sistema',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'gray',
|
|
||||||
bgColor: 'bg-gray-100',
|
|
||||||
textColor: 'text-gray-600',
|
|
||||||
borderColor: 'border-gray-200'
|
|
||||||
},
|
|
||||||
[ActivityType.QUALITY]: {
|
|
||||||
label: 'Calidad',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-green-100',
|
|
||||||
textColor: 'text-green-600',
|
|
||||||
borderColor: 'border-green-200'
|
|
||||||
},
|
|
||||||
[ActivityType.SUPPLIER]: {
|
|
||||||
label: 'Proveedores',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'teal',
|
|
||||||
bgColor: 'bg-teal-100',
|
|
||||||
textColor: 'text-teal-600',
|
|
||||||
borderColor: 'border-teal-200'
|
|
||||||
},
|
|
||||||
[ActivityType.FINANCE]: {
|
|
||||||
label: 'Finanzas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'emerald',
|
|
||||||
bgColor: 'bg-emerald-100',
|
|
||||||
textColor: 'text-emerald-600',
|
|
||||||
borderColor: 'border-emerald-200'
|
|
||||||
},
|
|
||||||
[ActivityType.ALERT]: {
|
|
||||||
label: 'Alertas',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
color: 'red',
|
|
||||||
bgColor: 'bg-red-100',
|
|
||||||
textColor: 'text-red-600',
|
|
||||||
borderColor: 'border-red-200'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
|
||||||
[ActivityStatus.SUCCESS]: { color: 'green', bgColor: 'bg-green-500' },
|
|
||||||
[ActivityStatus.WARNING]: { color: 'yellow', bgColor: 'bg-yellow-500' },
|
|
||||||
[ActivityStatus.ERROR]: { color: 'red', bgColor: 'bg-red-500' },
|
|
||||||
[ActivityStatus.INFO]: { color: 'blue', bgColor: 'bg-blue-500' },
|
|
||||||
[ActivityStatus.PENDING]: { color: 'gray', bgColor: 'bg-gray-500' },
|
|
||||||
[ActivityStatus.IN_PROGRESS]: { color: 'purple', bgColor: 'bg-purple-500' },
|
|
||||||
[ActivityStatus.CANCELLED]: { color: 'gray', bgColor: 'bg-gray-400' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRelativeTime = (timestamp: string): string => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const diffInMs = now.getTime() - date.getTime();
|
|
||||||
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
|
|
||||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
||||||
const diffInDays = Math.floor(diffInHours / 24);
|
|
||||||
|
|
||||||
if (diffInMinutes < 1) {
|
|
||||||
return 'Ahora mismo';
|
|
||||||
} else if (diffInMinutes < 60) {
|
|
||||||
return `Hace ${diffInMinutes} min`;
|
|
||||||
} else if (diffInHours < 24) {
|
|
||||||
return `Hace ${diffInHours}h`;
|
|
||||||
} else if (diffInDays === 1) {
|
|
||||||
return 'Ayer';
|
|
||||||
} else if (diffInDays < 7) {
|
|
||||||
return `Hace ${diffInDays} días`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString('es-ES', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const RecentActivity: React.FC<RecentActivityProps> = ({
|
|
||||||
activities,
|
|
||||||
maxItems = 10,
|
|
||||||
showTimestamp = true,
|
|
||||||
showUserAvatar = true,
|
|
||||||
showTypeIcons = true,
|
|
||||||
compact = false,
|
|
||||||
allowFiltering = true,
|
|
||||||
filterTypes = Object.values(ActivityType),
|
|
||||||
defaultFilter = 'all',
|
|
||||||
hasMore = false,
|
|
||||||
isLoading = false,
|
|
||||||
onLoadMore,
|
|
||||||
onActivityClick,
|
|
||||||
onRefresh,
|
|
||||||
className,
|
|
||||||
'aria-label': ariaLabel = 'Actividad reciente',
|
|
||||||
emptyMessage = 'No hay actividad reciente'
|
|
||||||
}) => {
|
|
||||||
const [activeFilter, setActiveFilter] = useState<ActivityType | 'all'>(defaultFilter);
|
|
||||||
|
|
||||||
const filteredActivities = useMemo(() => {
|
|
||||||
let filtered = activities;
|
|
||||||
|
|
||||||
if (activeFilter !== 'all') {
|
|
||||||
filtered = activities.filter(activity => activity.type === activeFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered.slice(0, maxItems);
|
|
||||||
}, [activities, activeFilter, maxItems]);
|
|
||||||
|
|
||||||
const handleActivityClick = useCallback((activity: ActivityItem) => {
|
|
||||||
onActivityClick?.(activity);
|
|
||||||
if (activity.onClick) {
|
|
||||||
activity.onClick();
|
|
||||||
}
|
|
||||||
}, [onActivityClick]);
|
|
||||||
|
|
||||||
const renderActivityItem = (activity: ActivityItem) => {
|
|
||||||
const config = ACTIVITY_CONFIG[activity.type] || {
|
|
||||||
label: 'Actividad',
|
|
||||||
icon: <div className="w-4 h-4 bg-gray-300 rounded" />,
|
|
||||||
color: 'gray',
|
|
||||||
bgColor: 'bg-gray-100',
|
|
||||||
textColor: 'text-gray-600',
|
|
||||||
borderColor: 'border-gray-200'
|
|
||||||
};
|
|
||||||
const statusConfig = activity.status ? STATUS_CONFIG[activity.status] : null;
|
|
||||||
|
|
||||||
const itemClasses = clsx(
|
|
||||||
'group relative flex items-start gap-3 p-3 rounded-lg transition-all duration-200',
|
|
||||||
'hover:bg-gray-50 hover:shadow-sm',
|
|
||||||
{
|
|
||||||
'cursor-pointer': activity.onClick || activity.href,
|
|
||||||
'p-2': compact,
|
|
||||||
'border-l-4': !compact,
|
|
||||||
[config.borderColor]: !compact
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={activity.id}
|
|
||||||
className={itemClasses}
|
|
||||||
onClick={activity.onClick || activity.href ? () => handleActivityClick(activity) : undefined}
|
|
||||||
role={activity.onClick || activity.href ? 'button' : undefined}
|
|
||||||
tabIndex={activity.onClick || activity.href ? 0 : undefined}
|
|
||||||
>
|
|
||||||
{/* Timeline indicator */}
|
|
||||||
<div className="relative flex-shrink-0">
|
|
||||||
{showTypeIcons && (
|
|
||||||
<div className={clsx(
|
|
||||||
'flex items-center justify-center rounded-full',
|
|
||||||
{
|
|
||||||
'w-8 h-8': !compact,
|
|
||||||
'w-6 h-6': compact
|
|
||||||
},
|
|
||||||
config.bgColor,
|
|
||||||
config.textColor
|
|
||||||
)}>
|
|
||||||
{activity.icon || config.icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status indicator */}
|
|
||||||
{activity.status && statusConfig && (
|
|
||||||
<div className={clsx(
|
|
||||||
'absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-white',
|
|
||||||
statusConfig.bgColor
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className={clsx(
|
|
||||||
'font-medium text-gray-900 truncate',
|
|
||||||
{
|
|
||||||
'text-sm': compact,
|
|
||||||
'text-base': !compact
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{activity.title}
|
|
||||||
</p>
|
|
||||||
<p className={clsx(
|
|
||||||
'text-gray-600 mt-1',
|
|
||||||
{
|
|
||||||
'text-xs line-clamp-1': compact,
|
|
||||||
'text-sm line-clamp-2': !compact
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{activity.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* User info */}
|
|
||||||
{activity.user && showUserAvatar && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Avatar
|
|
||||||
size="xs"
|
|
||||||
src={activity.user.avatar}
|
|
||||||
alt={activity.user.name}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{activity.user.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
{showTimestamp && (
|
|
||||||
<time className={clsx(
|
|
||||||
'text-gray-500 flex-shrink-0',
|
|
||||||
{
|
|
||||||
'text-xs': compact,
|
|
||||||
'text-sm': !compact
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{formatRelativeTime(activity.timestamp)}
|
|
||||||
</time>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activities.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<div className="text-gray-400 mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 mx-auto"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>{emptyMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx('space-y-4', className)} aria-label={ariaLabel}>
|
|
||||||
{/* Filters */}
|
|
||||||
{allowFiltering && filterTypes.length > 1 && (
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-gray-200">
|
|
||||||
<Button
|
|
||||||
variant={activeFilter === 'all' ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setActiveFilter('all')}
|
|
||||||
>
|
|
||||||
Todos
|
|
||||||
</Button>
|
|
||||||
{filterTypes.map((type) => {
|
|
||||||
const config = ACTIVITY_CONFIG[type] || {
|
|
||||||
label: 'Actividad',
|
|
||||||
icon: <div className="w-4 h-4 bg-gray-300 rounded" />,
|
|
||||||
color: 'gray',
|
|
||||||
bgColor: 'bg-gray-100',
|
|
||||||
textColor: 'text-gray-600',
|
|
||||||
borderColor: 'border-gray-200'
|
|
||||||
};
|
|
||||||
const count = activities.filter(a => a.type === type).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={type}
|
|
||||||
variant={activeFilter === type ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setActiveFilter(type)}
|
|
||||||
leftIcon={config.icon}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
{count > 0 && (
|
|
||||||
<Badge size="xs" className="ml-1">
|
|
||||||
{count}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{onRefresh && (
|
|
||||||
<div className="ml-auto">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRefresh}
|
|
||||||
aria-label="Actualizar actividad"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity list */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredActivities.map(renderActivityItem)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Load more */}
|
|
||||||
{hasMore && onLoadMore && !isLoading && (
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onLoadMore}
|
|
||||||
>
|
|
||||||
Ver más actividad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
RecentActivity.displayName = 'RecentActivity';
|
|
||||||
|
|
||||||
export default RecentActivity;
|
|
||||||
@@ -1,725 +0,0 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Card, CardHeader, CardBody } from '../../ui';
|
|
||||||
import { Button } from '../../ui';
|
|
||||||
import { Badge } from '../../ui';
|
|
||||||
import { Select } from '../../ui';
|
|
||||||
import { Input } from '../../ui';
|
|
||||||
import {
|
|
||||||
ForecastAlert,
|
|
||||||
ForecastAlertType,
|
|
||||||
AlertSeverity,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
|
|
||||||
export interface AlertsPanelProps {
|
|
||||||
className?: string;
|
|
||||||
title?: string;
|
|
||||||
alerts?: ForecastAlert[];
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
onAlertAction?: (alertId: string, action: 'acknowledge' | 'resolve' | 'snooze' | 'production_adjust' | 'inventory_check') => void;
|
|
||||||
onAlertDismiss?: (alertId: string) => void;
|
|
||||||
onBulkAction?: (alertIds: string[], action: string) => void;
|
|
||||||
showFilters?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
maxItems?: number;
|
|
||||||
autoRefresh?: boolean;
|
|
||||||
refreshInterval?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertFilter {
|
|
||||||
severity: AlertSeverity | 'all';
|
|
||||||
type: ForecastAlertType | 'all';
|
|
||||||
status: 'active' | 'acknowledged' | 'resolved' | 'all';
|
|
||||||
product: string;
|
|
||||||
dateRange: 'today' | 'week' | 'month' | 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertActionGroup {
|
|
||||||
critical: AlertAction[];
|
|
||||||
high: AlertAction[];
|
|
||||||
medium: AlertAction[];
|
|
||||||
low: AlertAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertAction {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
variant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPANISH_ALERT_TYPES: Record<ForecastAlertType, string> = {
|
|
||||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: 'Alta Demanda Predicha',
|
|
||||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: 'Baja Demanda Predicha',
|
|
||||||
[ForecastAlertType.ACCURACY_DROP]: 'Caída de Precisión',
|
|
||||||
[ForecastAlertType.MODEL_DRIFT]: 'Deriva del Modelo',
|
|
||||||
[ForecastAlertType.DATA_ANOMALY]: 'Anomalía de Datos',
|
|
||||||
[ForecastAlertType.MISSING_DATA]: 'Datos Faltantes',
|
|
||||||
[ForecastAlertType.SEASONAL_SHIFT]: 'Cambio Estacional',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SPANISH_SEVERITIES: Record<AlertSeverity, string> = {
|
|
||||||
[AlertSeverity.CRITICAL]: 'Crítica',
|
|
||||||
[AlertSeverity.HIGH]: 'Alta',
|
|
||||||
[AlertSeverity.MEDIUM]: 'Media',
|
|
||||||
[AlertSeverity.LOW]: 'Baja',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<AlertSeverity, string> = {
|
|
||||||
[AlertSeverity.CRITICAL]: 'text-red-600 bg-red-50 border-red-200',
|
|
||||||
[AlertSeverity.HIGH]: 'text-orange-600 bg-orange-50 border-orange-200',
|
|
||||||
[AlertSeverity.MEDIUM]: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
|
||||||
[AlertSeverity.LOW]: 'text-blue-600 bg-blue-50 border-blue-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEVERITY_BADGE_VARIANTS: Record<AlertSeverity, 'danger' | 'warning' | 'success' | 'info'> = {
|
|
||||||
[AlertSeverity.CRITICAL]: 'danger',
|
|
||||||
[AlertSeverity.HIGH]: 'warning',
|
|
||||||
[AlertSeverity.MEDIUM]: 'warning',
|
|
||||||
[AlertSeverity.LOW]: 'info',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALERT_TYPE_ICONS: Record<ForecastAlertType, string> = {
|
|
||||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: '📈',
|
|
||||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: '📉',
|
|
||||||
[ForecastAlertType.ACCURACY_DROP]: '🎯',
|
|
||||||
[ForecastAlertType.MODEL_DRIFT]: '🔄',
|
|
||||||
[ForecastAlertType.DATA_ANOMALY]: '⚠️',
|
|
||||||
[ForecastAlertType.MISSING_DATA]: '📊',
|
|
||||||
[ForecastAlertType.SEASONAL_SHIFT]: '🍂',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALERT_ACTIONS: AlertActionGroup = {
|
|
||||||
critical: [
|
|
||||||
{
|
|
||||||
id: 'production_adjust',
|
|
||||||
label: 'Ajustar Producción',
|
|
||||||
icon: '🏭',
|
|
||||||
variant: 'primary',
|
|
||||||
description: 'Modificar inmediatamente el plan de producción',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'inventory_check',
|
|
||||||
label: 'Verificar Inventario',
|
|
||||||
icon: '📦',
|
|
||||||
variant: 'warning',
|
|
||||||
description: 'Revisar niveles de stock y materias primas',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'emergency_order',
|
|
||||||
label: 'Pedido Urgente',
|
|
||||||
icon: '🚚',
|
|
||||||
variant: 'danger',
|
|
||||||
description: 'Realizar pedido urgente a proveedores',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
high: [
|
|
||||||
{
|
|
||||||
id: 'production_adjust',
|
|
||||||
label: 'Ajustar Producción',
|
|
||||||
icon: '🏭',
|
|
||||||
variant: 'primary',
|
|
||||||
description: 'Ajustar plan de producción para mañana',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'inventory_alert',
|
|
||||||
label: 'Alerta Inventario',
|
|
||||||
icon: '📋',
|
|
||||||
variant: 'warning',
|
|
||||||
description: 'Crear alerta de inventario preventiva',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'team_notify',
|
|
||||||
label: 'Notificar Equipo',
|
|
||||||
icon: '👥',
|
|
||||||
variant: 'secondary',
|
|
||||||
description: 'Informar al equipo de producción',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
medium: [
|
|
||||||
{
|
|
||||||
id: 'production_review',
|
|
||||||
label: 'Revisar Producción',
|
|
||||||
icon: '📊',
|
|
||||||
variant: 'secondary',
|
|
||||||
description: 'Revisar plan de producción esta semana',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'monitor',
|
|
||||||
label: 'Monitorear',
|
|
||||||
icon: '👁️',
|
|
||||||
variant: 'secondary',
|
|
||||||
description: 'Mantener bajo observación',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
low: [
|
|
||||||
{
|
|
||||||
id: 'monitor',
|
|
||||||
label: 'Monitorear',
|
|
||||||
icon: '👁️',
|
|
||||||
variant: 'secondary',
|
|
||||||
description: 'Mantener bajo observación',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'data_review',
|
|
||||||
label: 'Revisar Datos',
|
|
||||||
icon: '🔍',
|
|
||||||
variant: 'secondary',
|
|
||||||
description: 'Revisar calidad de los datos',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const RECOMMENDATION_MESSAGES: Record<ForecastAlertType, (product: string, value: number) => string> = {
|
|
||||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: (product, value) =>
|
|
||||||
`Se predice un aumento del ${value}% en la demanda de ${product}. Considera aumentar la producción.`,
|
|
||||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: (product, value) =>
|
|
||||||
`Se predice una disminución del ${Math.abs(value)}% en la demanda de ${product}. Considera reducir la producción para evitar desperdicios.`,
|
|
||||||
[ForecastAlertType.ACCURACY_DROP]: (product, value) =>
|
|
||||||
`La precisión del modelo para ${product} ha disminuido al ${value}%. Es recomendable reentrenar el modelo.`,
|
|
||||||
[ForecastAlertType.MODEL_DRIFT]: (product, value) =>
|
|
||||||
`Detectada deriva en el modelo de ${product}. Los patrones de demanda han cambiado significativamente.`,
|
|
||||||
[ForecastAlertType.DATA_ANOMALY]: (product, value) =>
|
|
||||||
`Anomalía detectada en los datos de ${product}. Verifica la calidad de los datos de entrada.`,
|
|
||||||
[ForecastAlertType.MISSING_DATA]: (product, value) =>
|
|
||||||
`Faltan ${value} días de datos para ${product}. Esto puede afectar la precisión de las predicciones.`,
|
|
||||||
[ForecastAlertType.SEASONAL_SHIFT]: (product, value) =>
|
|
||||||
`Detectado cambio en el patrón estacional de ${product}. El pico de demanda se ha adelantado/retrasado.`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
|
||||||
className,
|
|
||||||
title = 'Alertas y Recomendaciones',
|
|
||||||
alerts = [],
|
|
||||||
loading = false,
|
|
||||||
error = null,
|
|
||||||
onAlertAction,
|
|
||||||
onAlertDismiss,
|
|
||||||
onBulkAction,
|
|
||||||
showFilters = true,
|
|
||||||
compact = false,
|
|
||||||
maxItems,
|
|
||||||
autoRefresh = false,
|
|
||||||
refreshInterval = 30000,
|
|
||||||
}) => {
|
|
||||||
const [filters, setFilters] = useState<AlertFilter>({
|
|
||||||
severity: 'all',
|
|
||||||
type: 'all',
|
|
||||||
status: 'active',
|
|
||||||
product: '',
|
|
||||||
dateRange: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
|
||||||
const [expandedAlerts, setExpandedAlerts] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Filter alerts
|
|
||||||
const filteredAlerts = useMemo(() => {
|
|
||||||
let filtered = [...alerts];
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (filters.status !== 'all') {
|
|
||||||
filtered = filtered.filter(alert => {
|
|
||||||
if (filters.status === 'active') return alert.is_active && !alert.acknowledged_at && !alert.resolved_at;
|
|
||||||
if (filters.status === 'acknowledged') return !!alert.acknowledged_at && !alert.resolved_at;
|
|
||||||
if (filters.status === 'resolved') return !!alert.resolved_at;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by severity
|
|
||||||
if (filters.severity !== 'all') {
|
|
||||||
filtered = filtered.filter(alert => alert.severity === filters.severity);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (filters.type !== 'all') {
|
|
||||||
filtered = filtered.filter(alert => alert.alert_type === filters.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by product
|
|
||||||
if (filters.product) {
|
|
||||||
filtered = filtered.filter(alert =>
|
|
||||||
alert.product_name?.toLowerCase().includes(filters.product.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by date range
|
|
||||||
if (filters.dateRange !== 'all') {
|
|
||||||
const now = new Date();
|
|
||||||
const filterDate = new Date();
|
|
||||||
|
|
||||||
switch (filters.dateRange) {
|
|
||||||
case 'today':
|
|
||||||
filterDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
filterDate.setDate(now.getDate() - 7);
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
filterDate.setMonth(now.getMonth() - 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered = filtered.filter(alert => new Date(alert.created_at) >= filterDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by severity and creation date
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
const severityOrder: Record<AlertSeverity, number> = {
|
|
||||||
[AlertSeverity.CRITICAL]: 4,
|
|
||||||
[AlertSeverity.HIGH]: 3,
|
|
||||||
[AlertSeverity.MEDIUM]: 2,
|
|
||||||
[AlertSeverity.LOW]: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (a.severity !== b.severity) {
|
|
||||||
return severityOrder[b.severity] - severityOrder[a.severity];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limit items if specified
|
|
||||||
if (maxItems) {
|
|
||||||
filtered = filtered.slice(0, maxItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [alerts, filters, maxItems]);
|
|
||||||
|
|
||||||
// Get alert statistics
|
|
||||||
const alertStats = useMemo(() => {
|
|
||||||
const stats = {
|
|
||||||
total: alerts.length,
|
|
||||||
active: alerts.filter(a => a.is_active && !a.acknowledged_at && !a.resolved_at).length,
|
|
||||||
critical: alerts.filter(a => a.severity === AlertSeverity.CRITICAL && a.is_active).length,
|
|
||||||
high: alerts.filter(a => a.severity === AlertSeverity.HIGH && a.is_active).length,
|
|
||||||
};
|
|
||||||
return stats;
|
|
||||||
}, [alerts]);
|
|
||||||
|
|
||||||
// Handle alert expansion
|
|
||||||
const toggleAlertExpansion = useCallback((alertId: string) => {
|
|
||||||
setExpandedAlerts(prev =>
|
|
||||||
prev.includes(alertId)
|
|
||||||
? prev.filter(id => id !== alertId)
|
|
||||||
: [...prev, alertId]
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle alert selection
|
|
||||||
const toggleAlertSelection = useCallback((alertId: string) => {
|
|
||||||
setSelectedAlerts(prev =>
|
|
||||||
prev.includes(alertId)
|
|
||||||
? prev.filter(id => id !== alertId)
|
|
||||||
: [...prev, alertId]
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle bulk selection
|
|
||||||
const handleSelectAll = useCallback(() => {
|
|
||||||
const activeAlerts = filteredAlerts.filter(a => a.is_active).map(a => a.id);
|
|
||||||
setSelectedAlerts(prev =>
|
|
||||||
prev.length === activeAlerts.length ? [] : activeAlerts
|
|
||||||
);
|
|
||||||
}, [filteredAlerts]);
|
|
||||||
|
|
||||||
// Get available actions for alert
|
|
||||||
const getAlertActions = useCallback((alert: ForecastAlert): AlertAction[] => {
|
|
||||||
return ALERT_ACTIONS[alert.severity] || [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get recommendation message
|
|
||||||
const getRecommendation = useCallback((alert: ForecastAlert): string => {
|
|
||||||
const generator = RECOMMENDATION_MESSAGES[alert.alert_type];
|
|
||||||
if (!generator) return alert.message;
|
|
||||||
|
|
||||||
const value = alert.predicted_value || alert.threshold_value || 0;
|
|
||||||
return generator(alert.product_name || 'Producto', value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Format time ago
|
|
||||||
const formatTimeAgo = useCallback((dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'Ahora mismo';
|
|
||||||
if (diffMins < 60) return `Hace ${diffMins} min`;
|
|
||||||
if (diffHours < 24) return `Hace ${diffHours}h`;
|
|
||||||
if (diffDays < 7) return `Hace ${diffDays}d`;
|
|
||||||
return date.toLocaleDateString('es-ES');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-color-primary"></div>
|
|
||||||
<span className="text-text-secondary">Cargando alertas...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={className} variant="outlined">
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-500 mb-2">
|
|
||||||
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
{alertStats.critical > 0 && (
|
|
||||||
<Badge variant="danger" className="animate-pulse">
|
|
||||||
{alertStats.critical} críticas
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{alertStats.high > 0 && (
|
|
||||||
<Badge variant="warning">
|
|
||||||
{alertStats.high} altas
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge variant="ghost">
|
|
||||||
{alertStats.active}/{alertStats.total} activas
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectedAlerts.length > 0 && onBulkAction && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onBulkAction(selectedAlerts, 'acknowledge')}
|
|
||||||
>
|
|
||||||
✓ Confirmar Todas
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onBulkAction(selectedAlerts, 'production_plan')}
|
|
||||||
>
|
|
||||||
📋 Plan Producción
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoRefresh && (
|
|
||||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
|
||||||
<div className="animate-spin w-3 h-3 border border-color-primary border-t-transparent rounded-full"></div>
|
|
||||||
Auto-actualización
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<div className="px-6 py-3 border-b border-border-primary bg-bg-secondary">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
||||||
<Select
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los estados</option>
|
|
||||||
<option value="active">Activas</option>
|
|
||||||
<option value="acknowledged">Confirmadas</option>
|
|
||||||
<option value="resolved">Resueltas</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.severity}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, severity: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Toda severidad</option>
|
|
||||||
{Object.entries(SPANISH_SEVERITIES).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.type}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, type: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los tipos</option>
|
|
||||||
{Object.entries(SPANISH_ALERT_TYPES).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar producto..."
|
|
||||||
value={filters.product}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, product: e.target.value }))}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.dateRange}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, dateRange: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todo el tiempo</option>
|
|
||||||
<option value="today">Hoy</option>
|
|
||||||
<option value="week">Última semana</option>
|
|
||||||
<option value="month">Último mes</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardBody padding={compact ? 'sm' : 'md'}>
|
|
||||||
{filteredAlerts.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-green-500 mb-2">
|
|
||||||
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary text-sm">No hay alertas que mostrar</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Bulk actions bar */}
|
|
||||||
{filteredAlerts.some(a => a.is_active) && (
|
|
||||||
<div className="flex items-center gap-3 pb-2 border-b border-border-secondary">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-input-border focus:ring-color-primary"
|
|
||||||
checked={selectedAlerts.length === filteredAlerts.filter(a => a.is_active).length}
|
|
||||||
onChange={handleSelectAll}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-text-secondary">
|
|
||||||
{selectedAlerts.length > 0
|
|
||||||
? `${selectedAlerts.length} alertas seleccionadas`
|
|
||||||
: 'Seleccionar todas'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alerts list */}
|
|
||||||
{filteredAlerts.map((alert) => {
|
|
||||||
const isExpanded = expandedAlerts.includes(alert.id);
|
|
||||||
const isSelected = selectedAlerts.includes(alert.id);
|
|
||||||
const availableActions = getAlertActions(alert);
|
|
||||||
const recommendation = getRecommendation(alert);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={alert.id}
|
|
||||||
className={clsx(
|
|
||||||
'rounded-lg border transition-all duration-200',
|
|
||||||
SEVERITY_COLORS[alert.severity],
|
|
||||||
{
|
|
||||||
'opacity-60': !alert.is_active || alert.resolved_at,
|
|
||||||
'ring-2 ring-color-primary': isSelected,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* Selection checkbox */}
|
|
||||||
{alert.is_active && !alert.resolved_at && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 rounded border-input-border focus:ring-color-primary"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => toggleAlertSelection(alert.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alert icon */}
|
|
||||||
<div className="text-2xl mt-1">
|
|
||||||
{ALERT_TYPE_ICONS[alert.alert_type]}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h4 className="font-semibold text-text-primary text-sm">
|
|
||||||
{alert.title}
|
|
||||||
</h4>
|
|
||||||
<Badge
|
|
||||||
variant={SEVERITY_BADGE_VARIANTS[alert.severity]}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{SPANISH_SEVERITIES[alert.severity]}
|
|
||||||
</Badge>
|
|
||||||
{alert.product_name && (
|
|
||||||
<Badge variant="ghost" size="sm">
|
|
||||||
{alert.product_name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-text-secondary text-sm mb-2">
|
|
||||||
{compact ? alert.message.slice(0, 100) + '...' : alert.message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!compact && (
|
|
||||||
<p className="text-text-primary text-sm mb-3 font-medium">
|
|
||||||
💡 {recommendation}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3 text-xs text-text-tertiary">
|
|
||||||
<span>{formatTimeAgo(alert.created_at)}</span>
|
|
||||||
{alert.acknowledged_at && (
|
|
||||||
<span>• Confirmada</span>
|
|
||||||
)}
|
|
||||||
{alert.resolved_at && (
|
|
||||||
<span>• Resuelta</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* Quick actions */}
|
|
||||||
{alert.is_active && !alert.resolved_at && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{availableActions.slice(0, compact ? 1 : 2).map((action) => (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
variant={action.variant as any}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
|
||||||
title={action.description}
|
|
||||||
>
|
|
||||||
{action.icon} {compact ? '' : action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{availableActions.length > (compact ? 1 : 2) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleAlertExpansion(alert.id)}
|
|
||||||
>
|
|
||||||
{isExpanded ? '▼' : '▶'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dismiss button */}
|
|
||||||
{onAlertDismiss && alert.is_active && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAlertDismiss(alert.id)}
|
|
||||||
title="Descartar alerta"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded actions */}
|
|
||||||
{isExpanded && availableActions.length > (compact ? 1 : 2) && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{availableActions.slice(compact ? 1 : 2).map((action) => (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
variant={action.variant as any}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
<span className="mr-2">{action.icon}</span>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-medium">{action.label}</div>
|
|
||||||
<div className="text-xs opacity-75">{action.description}</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alert details */}
|
|
||||||
{!compact && isExpanded && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<span className="text-text-secondary">Tipo:</span>
|
|
||||||
<div className="font-medium">{SPANISH_ALERT_TYPES[alert.alert_type]}</div>
|
|
||||||
</div>
|
|
||||||
{alert.predicted_value && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-secondary">Valor Predicho:</span>
|
|
||||||
<div className="font-medium">{alert.predicted_value}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{alert.threshold_value && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-secondary">Umbral:</span>
|
|
||||||
<div className="font-medium">{alert.threshold_value}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{alert.model_accuracy && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-secondary">Precisión Modelo:</span>
|
|
||||||
<div className="font-medium">{(alert.model_accuracy * 100).toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AlertsPanel;
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
ReferenceLine,
|
|
||||||
Area,
|
|
||||||
ComposedChart,
|
|
||||||
Legend,
|
|
||||||
Brush,
|
|
||||||
ReferenceDot,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Card, CardHeader, CardBody } from '../../ui';
|
|
||||||
import { Button } from '../../ui';
|
|
||||||
import { Select } from '../../ui';
|
|
||||||
import { Badge } from '../../ui';
|
|
||||||
import {
|
|
||||||
ForecastResponse,
|
|
||||||
DemandTrend,
|
|
||||||
TrendDirection,
|
|
||||||
WeatherCondition,
|
|
||||||
EventType,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
|
|
||||||
export interface DemandChartProps {
|
|
||||||
className?: string;
|
|
||||||
title?: string;
|
|
||||||
data?: DemandTrend[];
|
|
||||||
products?: string[];
|
|
||||||
selectedProducts?: string[];
|
|
||||||
onProductSelectionChange?: (products: string[]) => void;
|
|
||||||
timeframe?: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
|
||||||
onTimeframeChange?: (timeframe: 'weekly' | 'monthly' | 'quarterly' | 'yearly') => void;
|
|
||||||
showConfidenceInterval?: boolean;
|
|
||||||
showEvents?: boolean;
|
|
||||||
showWeatherOverlay?: boolean;
|
|
||||||
events?: Array<{
|
|
||||||
date: string;
|
|
||||||
type: EventType;
|
|
||||||
name: string;
|
|
||||||
impact: 'high' | 'medium' | 'low';
|
|
||||||
}>;
|
|
||||||
weatherData?: Array<{
|
|
||||||
date: string;
|
|
||||||
condition: WeatherCondition;
|
|
||||||
temperature: number;
|
|
||||||
}>;
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
height?: number;
|
|
||||||
onExport?: (format: 'png' | 'pdf' | 'csv') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChartDataPoint {
|
|
||||||
date: string;
|
|
||||||
actualDemand?: number;
|
|
||||||
predictedDemand?: number;
|
|
||||||
confidenceLower?: number;
|
|
||||||
confidenceUpper?: number;
|
|
||||||
accuracy?: number;
|
|
||||||
trendDirection?: TrendDirection;
|
|
||||||
seasonalFactor?: number;
|
|
||||||
anomalyScore?: number;
|
|
||||||
hasEvent?: boolean;
|
|
||||||
eventType?: EventType;
|
|
||||||
eventName?: string;
|
|
||||||
eventImpact?: 'high' | 'medium' | 'low';
|
|
||||||
weather?: WeatherCondition;
|
|
||||||
temperature?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRODUCT_COLORS = [
|
|
||||||
'#8884d8', // Croissants
|
|
||||||
'#82ca9d', // Pan de molde
|
|
||||||
'#ffc658', // Roscón de Reyes
|
|
||||||
'#ff7c7c', // Torrijas
|
|
||||||
'#8dd1e1', // Magdalenas
|
|
||||||
'#d084d0', // Empanadas
|
|
||||||
'#ffb347', // Tarta de Santiago
|
|
||||||
'#87ceeb', // Mazapán
|
|
||||||
];
|
|
||||||
|
|
||||||
const EVENT_ICONS: Record<EventType, string> = {
|
|
||||||
[EventType.HOLIDAY]: '🎉',
|
|
||||||
[EventType.FESTIVAL]: '🎪',
|
|
||||||
[EventType.SPORTS_EVENT]: '⚽',
|
|
||||||
[EventType.WEATHER_EVENT]: '🌧️',
|
|
||||||
[EventType.SCHOOL_EVENT]: '🎒',
|
|
||||||
[EventType.CONCERT]: '🎵',
|
|
||||||
[EventType.CONFERENCE]: '📊',
|
|
||||||
[EventType.CONSTRUCTION]: '🚧',
|
|
||||||
};
|
|
||||||
|
|
||||||
const WEATHER_ICONS: Record<WeatherCondition, string> = {
|
|
||||||
[WeatherCondition.SUNNY]: '☀️',
|
|
||||||
[WeatherCondition.CLOUDY]: '☁️',
|
|
||||||
[WeatherCondition.RAINY]: '🌧️',
|
|
||||||
[WeatherCondition.STORMY]: '⛈️',
|
|
||||||
[WeatherCondition.SNOWY]: '❄️',
|
|
||||||
[WeatherCondition.FOGGY]: '🌫️',
|
|
||||||
[WeatherCondition.WINDY]: '🌪️',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SPANISH_EVENT_NAMES: Record<EventType, string> = {
|
|
||||||
[EventType.HOLIDAY]: 'Festividad',
|
|
||||||
[EventType.FESTIVAL]: 'Festival',
|
|
||||||
[EventType.SPORTS_EVENT]: 'Evento Deportivo',
|
|
||||||
[EventType.WEATHER_EVENT]: 'Evento Climático',
|
|
||||||
[EventType.SCHOOL_EVENT]: 'Evento Escolar',
|
|
||||||
[EventType.CONCERT]: 'Concierto',
|
|
||||||
[EventType.CONFERENCE]: 'Conferencia',
|
|
||||||
[EventType.CONSTRUCTION]: 'Construcción',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DemandChart: React.FC<DemandChartProps> = ({
|
|
||||||
className,
|
|
||||||
title = 'Predicción de Demanda',
|
|
||||||
data = [],
|
|
||||||
products = [],
|
|
||||||
selectedProducts = [],
|
|
||||||
onProductSelectionChange,
|
|
||||||
timeframe = 'weekly',
|
|
||||||
onTimeframeChange,
|
|
||||||
showConfidenceInterval = true,
|
|
||||||
showEvents = true,
|
|
||||||
showWeatherOverlay = false,
|
|
||||||
events = [],
|
|
||||||
weatherData = [],
|
|
||||||
loading = false,
|
|
||||||
error = null,
|
|
||||||
height = 400,
|
|
||||||
onExport,
|
|
||||||
}) => {
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<{ start?: Date; end?: Date }>({});
|
|
||||||
const [zoomedData, setZoomedData] = useState<ChartDataPoint[]>([]);
|
|
||||||
const [hoveredPoint, setHoveredPoint] = useState<ChartDataPoint | null>(null);
|
|
||||||
|
|
||||||
// Process and merge data with events and weather
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
const processedData: ChartDataPoint[] = data.map(point => {
|
|
||||||
const dateStr = point.date;
|
|
||||||
const event = events.find(e => e.date === dateStr);
|
|
||||||
const weather = weatherData.find(w => w.date === dateStr);
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: dateStr,
|
|
||||||
actualDemand: point.actual_demand,
|
|
||||||
predictedDemand: point.predicted_demand,
|
|
||||||
confidenceLower: point.confidence_lower,
|
|
||||||
confidenceUpper: point.confidence_upper,
|
|
||||||
accuracy: point.accuracy,
|
|
||||||
trendDirection: point.trend_direction,
|
|
||||||
seasonalFactor: point.seasonal_factor,
|
|
||||||
anomalyScore: point.anomaly_score,
|
|
||||||
hasEvent: !!event,
|
|
||||||
eventType: event?.type,
|
|
||||||
eventName: event?.name,
|
|
||||||
eventImpact: event?.impact,
|
|
||||||
weather: weather?.condition,
|
|
||||||
temperature: weather?.temperature,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return processedData;
|
|
||||||
}, [data, events, weatherData]);
|
|
||||||
|
|
||||||
// Filter data based on selected period
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!selectedPeriod.start || !selectedPeriod.end) {
|
|
||||||
return chartData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return chartData.filter(point => {
|
|
||||||
const pointDate = new Date(point.date);
|
|
||||||
return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!;
|
|
||||||
});
|
|
||||||
}, [chartData, selectedPeriod]);
|
|
||||||
|
|
||||||
// Update zoomed data when filtered data changes
|
|
||||||
useEffect(() => {
|
|
||||||
setZoomedData(filteredData);
|
|
||||||
}, [filteredData]);
|
|
||||||
|
|
||||||
// Handle brush selection
|
|
||||||
const handleBrushChange = useCallback((brushData: any) => {
|
|
||||||
if (brushData && brushData.startIndex !== undefined && brushData.endIndex !== undefined) {
|
|
||||||
const newData = filteredData.slice(brushData.startIndex, brushData.endIndex + 1);
|
|
||||||
setZoomedData(newData);
|
|
||||||
} else {
|
|
||||||
setZoomedData(filteredData);
|
|
||||||
}
|
|
||||||
}, [filteredData]);
|
|
||||||
|
|
||||||
// Reset zoom
|
|
||||||
const handleResetZoom = useCallback(() => {
|
|
||||||
setZoomedData(filteredData);
|
|
||||||
setSelectedPeriod({});
|
|
||||||
}, [filteredData]);
|
|
||||||
|
|
||||||
// Custom tooltip
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
|
||||||
|
|
||||||
const data = payload[0].payload as ChartDataPoint;
|
|
||||||
const formattedDate = new Date(label).toLocaleDateString('es-ES', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-4 max-w-sm">
|
|
||||||
<h4 className="font-semibold text-text-primary mb-2">{formattedDate}</h4>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{data.actualDemand !== undefined && (
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Demanda Real:</span>
|
|
||||||
<span className="font-medium text-blue-600">{data.actualDemand}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.predictedDemand !== undefined && (
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Demanda Predicha:</span>
|
|
||||||
<span className="font-medium text-green-600">{data.predictedDemand.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showConfidenceInterval && data.confidenceLower !== undefined && data.confidenceUpper !== undefined && (
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Intervalo de Confianza:</span>
|
|
||||||
<span className="text-sm text-text-tertiary">
|
|
||||||
{data.confidenceLower.toFixed(1)} - {data.confidenceUpper.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.accuracy !== undefined && (
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Precisión:</span>
|
|
||||||
<Badge
|
|
||||||
variant={data.accuracy > 0.8 ? 'success' : data.accuracy > 0.6 ? 'warning' : 'danger'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{(data.accuracy * 100).toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showEvents && data.hasEvent && (
|
|
||||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{EVENT_ICONS[data.eventType!]}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-text-primary text-sm">{data.eventName}</div>
|
|
||||||
<div className="text-xs text-text-tertiary">
|
|
||||||
{SPANISH_EVENT_NAMES[data.eventType!]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={data.eventImpact === 'high' ? 'danger' : data.eventImpact === 'medium' ? 'warning' : 'success'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{data.eventImpact === 'high' ? 'Alto' : data.eventImpact === 'medium' ? 'Medio' : 'Bajo'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showWeatherOverlay && data.weather && (
|
|
||||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{WEATHER_ICONS[data.weather]}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm text-text-primary capitalize">
|
|
||||||
{data.weather.replace('_', ' ')}
|
|
||||||
</div>
|
|
||||||
{data.temperature && (
|
|
||||||
<div className="text-xs text-text-tertiary">
|
|
||||||
{data.temperature}°C
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event reference dots
|
|
||||||
const renderEventDots = () => {
|
|
||||||
if (!showEvents) return null;
|
|
||||||
|
|
||||||
return events
|
|
||||||
.filter(event => {
|
|
||||||
const eventData = zoomedData.find(d => d.date === event.date);
|
|
||||||
return eventData;
|
|
||||||
})
|
|
||||||
.map((event, index) => {
|
|
||||||
const eventData = zoomedData.find(d => d.date === event.date);
|
|
||||||
if (!eventData) return null;
|
|
||||||
|
|
||||||
const yValue = eventData.predictedDemand || eventData.actualDemand || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReferenceDot
|
|
||||||
key={`event-${index}`}
|
|
||||||
x={event.date}
|
|
||||||
y={yValue}
|
|
||||||
r={8}
|
|
||||||
fill={event.impact === 'high' ? '#ef4444' : event.impact === 'medium' ? '#f59e0b' : '#10b981'}
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
|
||||||
<span className="text-text-secondary">Cargando gráfico de demanda...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={className} variant="outlined">
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-500 mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (zoomedData.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-text-tertiary mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary">No hay datos de demanda disponibles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
{/* Time period selector */}
|
|
||||||
<Select
|
|
||||||
value={timeframe}
|
|
||||||
onChange={(value) => onTimeframeChange?.(value as 'weekly' | 'monthly' | 'quarterly' | 'yearly')}
|
|
||||||
className="w-32"
|
|
||||||
>
|
|
||||||
<option value="weekly">Semanal</option>
|
|
||||||
<option value="monthly">Mensual</option>
|
|
||||||
<option value="quarterly">Trimestral</option>
|
|
||||||
<option value="yearly">Anual</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Export options */}
|
|
||||||
{onExport && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onExport('png')}
|
|
||||||
title="Exportar como PNG"
|
|
||||||
>
|
|
||||||
📊
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onExport('csv')}
|
|
||||||
title="Exportar datos CSV"
|
|
||||||
>
|
|
||||||
📋
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reset zoom */}
|
|
||||||
{zoomedData.length !== filteredData.length && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleResetZoom}>
|
|
||||||
Restablecer Zoom
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody padding="lg">
|
|
||||||
<div style={{ width: '100%', height }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<ComposedChart data={zoomedData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
stroke="#6b7280"
|
|
||||||
fontSize={12}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
return timeframe === 'weekly'
|
|
||||||
? date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' })
|
|
||||||
: timeframe === 'monthly'
|
|
||||||
? date.toLocaleDateString('es-ES', { month: 'short', year: '2-digit' })
|
|
||||||
: date.getFullYear().toString();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="#6b7280"
|
|
||||||
fontSize={12}
|
|
||||||
tickFormatter={(value) => `${value}`}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend />
|
|
||||||
|
|
||||||
{/* Confidence interval area */}
|
|
||||||
{showConfidenceInterval && (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="confidenceUpper"
|
|
||||||
stackId={1}
|
|
||||||
stroke="none"
|
|
||||||
fill="#10b98120"
|
|
||||||
fillOpacity={0.3}
|
|
||||||
name="Intervalo de Confianza Superior"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showConfidenceInterval && (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="confidenceLower"
|
|
||||||
stackId={1}
|
|
||||||
stroke="none"
|
|
||||||
fill="#ffffff"
|
|
||||||
name="Intervalo de Confianza Inferior"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actual demand line */}
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="actualDemand"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth={3}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6, stroke: '#3b82f6', strokeWidth: 2 }}
|
|
||||||
name="Demanda Real"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Predicted demand line */}
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="predictedDemand"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 4, stroke: '#10b981', strokeWidth: 2 }}
|
|
||||||
name="Demanda Predicha"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{renderEventDots()}
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Brush for zooming */}
|
|
||||||
{filteredData.length > 20 && (
|
|
||||||
<div className="mt-4" style={{ width: '100%', height: 80 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<LineChart data={filteredData}>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="predictedDemand"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={1}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
<Brush
|
|
||||||
dataKey="date"
|
|
||||||
height={30}
|
|
||||||
stroke="#8884d8"
|
|
||||||
onChange={handleBrushChange}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chart legend */}
|
|
||||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-0.5 bg-blue-500"></div>
|
|
||||||
<span className="text-text-secondary">Demanda Real</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-0.5 bg-green-500 border-dashed border-t-2 border-green-500"></div>
|
|
||||||
<span className="text-text-secondary">Demanda Predicha</span>
|
|
||||||
</div>
|
|
||||||
{showConfidenceInterval && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-2 bg-green-500 bg-opacity-20"></div>
|
|
||||||
<span className="text-text-secondary">Intervalo de Confianza</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showEvents && events.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
||||||
<span className="text-text-secondary">Eventos</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DemandChart;
|
|
||||||
@@ -1,653 +0,0 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { Card, CardHeader, CardBody } from '../../ui';
|
|
||||||
import { Button } from '../../ui';
|
|
||||||
import { Badge } from '../../ui';
|
|
||||||
import { Select } from '../../ui';
|
|
||||||
import { Input } from '../../ui';
|
|
||||||
import { Table, TableColumn } from '../../ui';
|
|
||||||
import {
|
|
||||||
ForecastResponse,
|
|
||||||
TrendDirection,
|
|
||||||
ModelType,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
|
|
||||||
export interface ForecastTableProps {
|
|
||||||
className?: string;
|
|
||||||
title?: string;
|
|
||||||
data?: ForecastResponse[];
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
pageSize?: number;
|
|
||||||
onExportCSV?: (data: ForecastResponse[]) => void;
|
|
||||||
onBulkAction?: (action: string, selectedItems: ForecastResponse[]) => void;
|
|
||||||
onProductClick?: (productName: string) => void;
|
|
||||||
showBulkActions?: boolean;
|
|
||||||
showFilters?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterState {
|
|
||||||
productName: string;
|
|
||||||
category: string;
|
|
||||||
accuracyRange: 'all' | 'high' | 'medium' | 'low';
|
|
||||||
trendDirection: TrendDirection | 'all';
|
|
||||||
confidenceLevel: number | 'all';
|
|
||||||
dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SortState {
|
|
||||||
field: string;
|
|
||||||
order: 'asc' | 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPANISH_TRENDS: Record<TrendDirection, string> = {
|
|
||||||
[TrendDirection.INCREASING]: 'Creciente',
|
|
||||||
[TrendDirection.DECREASING]: 'Decreciente',
|
|
||||||
[TrendDirection.STABLE]: 'Estable',
|
|
||||||
[TrendDirection.VOLATILE]: 'Volátil',
|
|
||||||
[TrendDirection.SEASONAL]: 'Estacional',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TREND_COLORS: Record<TrendDirection, string> = {
|
|
||||||
[TrendDirection.INCREASING]: 'text-green-600',
|
|
||||||
[TrendDirection.DECREASING]: 'text-red-600',
|
|
||||||
[TrendDirection.STABLE]: 'text-blue-600',
|
|
||||||
[TrendDirection.VOLATILE]: 'text-yellow-600',
|
|
||||||
[TrendDirection.SEASONAL]: 'text-purple-600',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TREND_ICONS: Record<TrendDirection, string> = {
|
|
||||||
[TrendDirection.INCREASING]: '↗️',
|
|
||||||
[TrendDirection.DECREASING]: '↘️',
|
|
||||||
[TrendDirection.STABLE]: '➡️',
|
|
||||||
[TrendDirection.VOLATILE]: '📈',
|
|
||||||
[TrendDirection.SEASONAL]: '🔄',
|
|
||||||
};
|
|
||||||
|
|
||||||
const BAKERY_CATEGORIES = [
|
|
||||||
{ value: 'all', label: 'Todas las categorías' },
|
|
||||||
{ value: 'pan', label: 'Pan y Bollería' },
|
|
||||||
{ value: 'dulces', label: 'Dulces y Postres' },
|
|
||||||
{ value: 'salados', label: 'Salados' },
|
|
||||||
{ value: 'estacional', label: 'Productos Estacionales' },
|
|
||||||
{ value: 'bebidas', label: 'Bebidas' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SPANISH_PRODUCTS = [
|
|
||||||
'Croissants',
|
|
||||||
'Pan de molde',
|
|
||||||
'Roscón de Reyes',
|
|
||||||
'Torrijas',
|
|
||||||
'Magdalenas',
|
|
||||||
'Empanadas',
|
|
||||||
'Tarta de Santiago',
|
|
||||||
'Mazapán',
|
|
||||||
'Pan tostado',
|
|
||||||
'Palmeras',
|
|
||||||
'Napolitanas',
|
|
||||||
'Ensaimadas',
|
|
||||||
'Churros',
|
|
||||||
'Polvorones',
|
|
||||||
'Turrones',
|
|
||||||
];
|
|
||||||
|
|
||||||
const ForecastTable: React.FC<ForecastTableProps> = ({
|
|
||||||
className,
|
|
||||||
title = 'Predicciones de Demanda',
|
|
||||||
data = [],
|
|
||||||
loading = false,
|
|
||||||
error = null,
|
|
||||||
pageSize = 20,
|
|
||||||
onExportCSV,
|
|
||||||
onBulkAction,
|
|
||||||
onProductClick,
|
|
||||||
showBulkActions = true,
|
|
||||||
showFilters = true,
|
|
||||||
compact = false,
|
|
||||||
}) => {
|
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
|
||||||
productName: '',
|
|
||||||
category: 'all',
|
|
||||||
accuracyRange: 'all',
|
|
||||||
trendDirection: 'all',
|
|
||||||
confidenceLevel: 'all',
|
|
||||||
dateRange: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [sort, setSort] = useState<SortState>({
|
|
||||||
field: 'forecast_date',
|
|
||||||
order: 'desc',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedRows, setSelectedRows] = useState<React.Key[]>([]);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
|
|
||||||
// Get product category (simplified mapping)
|
|
||||||
const getProductCategory = useCallback((productName: string): string => {
|
|
||||||
const name = productName.toLowerCase();
|
|
||||||
if (name.includes('pan') || name.includes('croissant') || name.includes('magdalena')) return 'pan';
|
|
||||||
if (name.includes('roscón') || name.includes('tarta') || name.includes('mazapán') || name.includes('polvorón')) return 'dulces';
|
|
||||||
if (name.includes('empanada') || name.includes('churro')) return 'salados';
|
|
||||||
if (name.includes('torrija') || name.includes('turrón')) return 'estacional';
|
|
||||||
return 'pan';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter and sort data
|
|
||||||
const filteredAndSortedData = useMemo(() => {
|
|
||||||
let filtered = [...data];
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
if (filters.productName) {
|
|
||||||
filtered = filtered.filter(item =>
|
|
||||||
item.product_name.toLowerCase().includes(filters.productName.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.category !== 'all') {
|
|
||||||
filtered = filtered.filter(item => getProductCategory(item.product_name) === filters.category);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.accuracyRange !== 'all') {
|
|
||||||
filtered = filtered.filter(item => {
|
|
||||||
const accuracy = item.accuracy_score || 0;
|
|
||||||
switch (filters.accuracyRange) {
|
|
||||||
case 'high': return accuracy >= 0.8;
|
|
||||||
case 'medium': return accuracy >= 0.6 && accuracy < 0.8;
|
|
||||||
case 'low': return accuracy < 0.6;
|
|
||||||
default: return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.trendDirection !== 'all') {
|
|
||||||
filtered = filtered.filter(item => {
|
|
||||||
// Determine trend from predicted vs historical (simplified)
|
|
||||||
const trend = item.predicted_demand > (item.actual_demand || 0) ? TrendDirection.INCREASING : TrendDirection.DECREASING;
|
|
||||||
return trend === filters.trendDirection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.confidenceLevel !== 'all') {
|
|
||||||
filtered = filtered.filter(item => item.confidence_level >= filters.confidenceLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.dateRange !== 'all') {
|
|
||||||
const now = new Date();
|
|
||||||
const filterDate = new Date();
|
|
||||||
|
|
||||||
switch (filters.dateRange) {
|
|
||||||
case 'today':
|
|
||||||
filterDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
filterDate.setDate(now.getDate() - 7);
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
filterDate.setMonth(now.getMonth() - 1);
|
|
||||||
break;
|
|
||||||
case 'quarter':
|
|
||||||
filterDate.setMonth(now.getMonth() - 3);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered = filtered.filter(item => new Date(item.forecast_date) >= filterDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
let aValue: any;
|
|
||||||
let bValue: any;
|
|
||||||
|
|
||||||
switch (sort.field) {
|
|
||||||
case 'product_name':
|
|
||||||
aValue = a.product_name;
|
|
||||||
bValue = b.product_name;
|
|
||||||
break;
|
|
||||||
case 'predicted_demand':
|
|
||||||
aValue = a.predicted_demand;
|
|
||||||
bValue = b.predicted_demand;
|
|
||||||
break;
|
|
||||||
case 'accuracy_score':
|
|
||||||
aValue = a.accuracy_score || 0;
|
|
||||||
bValue = b.accuracy_score || 0;
|
|
||||||
break;
|
|
||||||
case 'confidence_level':
|
|
||||||
aValue = a.confidence_level;
|
|
||||||
bValue = b.confidence_level;
|
|
||||||
break;
|
|
||||||
case 'forecast_date':
|
|
||||||
aValue = new Date(a.forecast_date);
|
|
||||||
bValue = new Date(b.forecast_date);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aValue < bValue) return sort.order === 'asc' ? -1 : 1;
|
|
||||||
if (aValue > bValue) return sort.order === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [data, filters, sort, getProductCategory]);
|
|
||||||
|
|
||||||
// Paginated data
|
|
||||||
const paginatedData = useMemo(() => {
|
|
||||||
const startIndex = (currentPage - 1) * pageSize;
|
|
||||||
return filteredAndSortedData.slice(startIndex, startIndex + pageSize);
|
|
||||||
}, [filteredAndSortedData, currentPage, pageSize]);
|
|
||||||
|
|
||||||
// Handle sort change
|
|
||||||
const handleSort = useCallback((field: string, order: 'asc' | 'desc' | null) => {
|
|
||||||
if (order === null) {
|
|
||||||
setSort({ field: 'forecast_date', order: 'desc' });
|
|
||||||
} else {
|
|
||||||
setSort({ field, order });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle filter change
|
|
||||||
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
|
|
||||||
setFilters(prev => ({ ...prev, [key]: value }));
|
|
||||||
setCurrentPage(1); // Reset to first page when filtering
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle bulk actions
|
|
||||||
const handleBulkAction = useCallback((action: string) => {
|
|
||||||
const selectedData = filteredAndSortedData.filter(item =>
|
|
||||||
selectedRows.includes(item.id)
|
|
||||||
);
|
|
||||||
onBulkAction?.(action, selectedData);
|
|
||||||
}, [filteredAndSortedData, selectedRows, onBulkAction]);
|
|
||||||
|
|
||||||
// Calculate accuracy percentage and trend
|
|
||||||
const getAccuracyInfo = useCallback((item: ForecastResponse) => {
|
|
||||||
const accuracy = item.accuracy_score || 0;
|
|
||||||
const percentage = (accuracy * 100).toFixed(1);
|
|
||||||
|
|
||||||
let variant: 'success' | 'warning' | 'danger' = 'success';
|
|
||||||
if (accuracy < 0.6) variant = 'danger';
|
|
||||||
else if (accuracy < 0.8) variant = 'warning';
|
|
||||||
|
|
||||||
return { percentage, variant };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get trend info
|
|
||||||
const getTrendInfo = useCallback((item: ForecastResponse) => {
|
|
||||||
// Simplified trend calculation
|
|
||||||
const trend = item.predicted_demand > (item.actual_demand || 0) ?
|
|
||||||
TrendDirection.INCREASING : TrendDirection.DECREASING;
|
|
||||||
|
|
||||||
return {
|
|
||||||
direction: trend,
|
|
||||||
label: SPANISH_TRENDS[trend],
|
|
||||||
color: TREND_COLORS[trend],
|
|
||||||
icon: TREND_ICONS[trend],
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns: TableColumn<ForecastResponse>[] = useMemo(() => {
|
|
||||||
const baseColumns: TableColumn<ForecastResponse>[] = [
|
|
||||||
{
|
|
||||||
key: 'product_name',
|
|
||||||
title: 'Producto',
|
|
||||||
dataIndex: 'product_name',
|
|
||||||
sortable: true,
|
|
||||||
width: compact ? 120 : 160,
|
|
||||||
render: (value: string, record: ForecastResponse) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onProductClick?.(value)}
|
|
||||||
className="font-medium text-text-primary hover:text-color-primary transition-colors duration-150"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>
|
|
||||||
<Badge variant="ghost" size="sm">
|
|
||||||
{getProductCategory(value)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'predicted_demand',
|
|
||||||
title: 'Demanda Predicha',
|
|
||||||
dataIndex: 'predicted_demand',
|
|
||||||
sortable: true,
|
|
||||||
align: 'right' as const,
|
|
||||||
width: compact ? 100 : 120,
|
|
||||||
render: (value: number) => (
|
|
||||||
<div className="font-semibold text-green-600">
|
|
||||||
{value.toFixed(0)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actual_demand',
|
|
||||||
title: 'Demanda Real',
|
|
||||||
dataIndex: 'actual_demand',
|
|
||||||
align: 'right' as const,
|
|
||||||
width: compact ? 80 : 100,
|
|
||||||
render: (value?: number) => (
|
|
||||||
<div className="font-medium text-blue-600">
|
|
||||||
{value ? value.toFixed(0) : '-'}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accuracy_score',
|
|
||||||
title: 'Precisión',
|
|
||||||
dataIndex: 'accuracy_score',
|
|
||||||
sortable: true,
|
|
||||||
align: 'center' as const,
|
|
||||||
width: 80,
|
|
||||||
render: (value: number | undefined, record: ForecastResponse) => {
|
|
||||||
const { percentage, variant } = getAccuracyInfo(record);
|
|
||||||
return (
|
|
||||||
<Badge variant={variant} size="sm">
|
|
||||||
{percentage}%
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'trend',
|
|
||||||
title: 'Tendencia',
|
|
||||||
align: 'center' as const,
|
|
||||||
width: compact ? 80 : 100,
|
|
||||||
render: (_, record: ForecastResponse) => {
|
|
||||||
const trend = getTrendInfo(record);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="text-lg">{trend.icon}</span>
|
|
||||||
<span className={clsx('text-xs font-medium', trend.color)}>
|
|
||||||
{compact ? '' : trend.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'confidence_interval',
|
|
||||||
title: 'Intervalo de Confianza',
|
|
||||||
align: 'center' as const,
|
|
||||||
width: compact ? 120 : 140,
|
|
||||||
render: (_, record: ForecastResponse) => (
|
|
||||||
<div className="text-sm text-text-secondary">
|
|
||||||
{record.confidence_lower.toFixed(0)} - {record.confidence_upper.toFixed(0)}
|
|
||||||
<div className="text-xs text-text-tertiary">
|
|
||||||
({(record.confidence_level * 100).toFixed(0)}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!compact) {
|
|
||||||
baseColumns.push(
|
|
||||||
{
|
|
||||||
key: 'forecast_date',
|
|
||||||
title: 'Fecha de Predicción',
|
|
||||||
dataIndex: 'forecast_date',
|
|
||||||
sortable: true,
|
|
||||||
width: 120,
|
|
||||||
render: (value: string) => (
|
|
||||||
<div className="text-sm">
|
|
||||||
{new Date(value).toLocaleDateString('es-ES', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'model_version',
|
|
||||||
title: 'Modelo',
|
|
||||||
dataIndex: 'model_version',
|
|
||||||
width: 80,
|
|
||||||
render: (value: string) => (
|
|
||||||
<Badge variant="outlined" size="sm">
|
|
||||||
v{value}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColumns;
|
|
||||||
}, [compact, onProductClick, getProductCategory, getAccuracyInfo, getTrendInfo]);
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
|
||||||
<span className="text-text-secondary">Cargando predicciones...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={className} variant="outlined">
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-500 mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
<Badge variant="ghost">
|
|
||||||
{filteredAndSortedData.length} predicciones
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{onExportCSV && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onExportCSV(filteredAndSortedData)}
|
|
||||||
>
|
|
||||||
📊 Exportar CSV
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showBulkActions && selectedRows.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleBulkAction('production_plan')}
|
|
||||||
>
|
|
||||||
📋 Plan de Producción
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleBulkAction('inventory_alert')}
|
|
||||||
>
|
|
||||||
🚨 Alertas de Inventario
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<div className="px-6 py-4 border-b border-border-primary bg-bg-secondary">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar producto..."
|
|
||||||
value={filters.productName}
|
|
||||||
onChange={(e) => handleFilterChange('productName', e.target.value)}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(value) => handleFilterChange('category', value)}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
{BAKERY_CATEGORIES.map(cat => (
|
|
||||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.accuracyRange}
|
|
||||||
onChange={(value) => handleFilterChange('accuracyRange', value)}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Toda precisión</option>
|
|
||||||
<option value="high">Alta (>80%)</option>
|
|
||||||
<option value="medium">Media (60-80%)</option>
|
|
||||||
<option value="low">Baja (<60%)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.trendDirection}
|
|
||||||
onChange={(value) => handleFilterChange('trendDirection', value)}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todas las tendencias</option>
|
|
||||||
{Object.entries(SPANISH_TRENDS).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.confidenceLevel}
|
|
||||||
onChange={(value) => handleFilterChange('confidenceLevel', value === 'all' ? 'all' : parseFloat(value))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Toda confianza</option>
|
|
||||||
<option value="0.9">Alta (>90%)</option>
|
|
||||||
<option value="0.8">Media (>80%)</option>
|
|
||||||
<option value="0.7">Baja (>70%)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.dateRange}
|
|
||||||
onChange={(value) => handleFilterChange('dateRange', value)}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todo el tiempo</option>
|
|
||||||
<option value="today">Hoy</option>
|
|
||||||
<option value="week">Última semana</option>
|
|
||||||
<option value="month">Último mes</option>
|
|
||||||
<option value="quarter">Último trimestre</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardBody padding="none">
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
rowSelection={showBulkActions ? {
|
|
||||||
selectedRowKeys: selectedRows,
|
|
||||||
onSelectAll: (selected, selectedRows, changeRows) => {
|
|
||||||
setSelectedRows(selected ? selectedRows.map(row => row.id) : []);
|
|
||||||
},
|
|
||||||
onSelect: (record, selected) => {
|
|
||||||
setSelectedRows(prev =>
|
|
||||||
selected
|
|
||||||
? [...prev, record.id]
|
|
||||||
: prev.filter(key => key !== record.id)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
} : undefined}
|
|
||||||
pagination={{
|
|
||||||
current: currentPage,
|
|
||||||
pageSize,
|
|
||||||
total: filteredAndSortedData.length,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (total, range) =>
|
|
||||||
`${range[0]}-${range[1]} de ${total} predicciones`,
|
|
||||||
onChange: (page, size) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sort={{
|
|
||||||
field: sort.field,
|
|
||||||
order: sort.order,
|
|
||||||
}}
|
|
||||||
onSort={handleSort}
|
|
||||||
size={compact ? 'sm' : 'md'}
|
|
||||||
hover={true}
|
|
||||||
expandable={!compact ? {
|
|
||||||
expandedRowRender: (record) => (
|
|
||||||
<div className="p-4 bg-bg-tertiary">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-text-primary mb-1">ID de Predicción</div>
|
|
||||||
<div className="text-text-secondary font-mono">{record.id}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-text-primary mb-1">Fecha de Creación</div>
|
|
||||||
<div className="text-text-secondary">
|
|
||||||
{new Date(record.created_at).toLocaleString('es-ES')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-text-primary mb-1">Factores Externos</div>
|
|
||||||
<div className="text-text-secondary">
|
|
||||||
{Object.entries(record.external_factors_impact).length > 0
|
|
||||||
? Object.keys(record.external_factors_impact).join(', ')
|
|
||||||
: 'Ninguno'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-text-primary mb-1">Componente Estacional</div>
|
|
||||||
<div className="text-text-secondary">
|
|
||||||
{record.seasonal_component
|
|
||||||
? (record.seasonal_component * 100).toFixed(1) + '%'
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
} : undefined}
|
|
||||||
/>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ForecastTable;
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import {
|
|
||||||
RadialBarChart,
|
|
||||||
RadialBar,
|
|
||||||
ResponsiveContainer,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
Tooltip,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
ComposedChart,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
ReferenceLine,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Card, CardHeader, CardBody } from '../../ui';
|
|
||||||
import { Button } from '../../ui';
|
|
||||||
import { Badge } from '../../ui';
|
|
||||||
import { Select } from '../../ui';
|
|
||||||
import {
|
|
||||||
SeasonalPattern,
|
|
||||||
SeasonalComponent,
|
|
||||||
HolidayEffect,
|
|
||||||
WeeklyPattern,
|
|
||||||
YearlyTrend,
|
|
||||||
Season,
|
|
||||||
SeasonalPeriod,
|
|
||||||
DayOfWeek,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
|
|
||||||
export interface SeasonalityIndicatorProps {
|
|
||||||
className?: string;
|
|
||||||
title?: string;
|
|
||||||
seasonalPatterns?: SeasonalPattern[];
|
|
||||||
selectedProduct?: string;
|
|
||||||
onProductChange?: (product: string) => void;
|
|
||||||
viewMode?: 'circular' | 'calendar' | 'heatmap' | 'trends';
|
|
||||||
onViewModeChange?: (mode: 'circular' | 'calendar' | 'heatmap' | 'trends') => void;
|
|
||||||
showComparison?: boolean;
|
|
||||||
comparisonYear?: number;
|
|
||||||
loading?: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthlyData {
|
|
||||||
month: string;
|
|
||||||
value: number;
|
|
||||||
strength: number;
|
|
||||||
color: string;
|
|
||||||
season: Season;
|
|
||||||
holidays: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeeklyData {
|
|
||||||
day: string;
|
|
||||||
dayShort: string;
|
|
||||||
value: number;
|
|
||||||
variance: number;
|
|
||||||
peakHours?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeatmapData {
|
|
||||||
month: number;
|
|
||||||
week: number;
|
|
||||||
intensity: number;
|
|
||||||
value: number;
|
|
||||||
holiday?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPANISH_MONTHS = [
|
|
||||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
|
||||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
|
||||||
];
|
|
||||||
|
|
||||||
const SPANISH_DAYS = [
|
|
||||||
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
|
|
||||||
];
|
|
||||||
|
|
||||||
const SPANISH_DAYS_SHORT = [
|
|
||||||
'L', 'M', 'X', 'J', 'V', 'S', 'D'
|
|
||||||
];
|
|
||||||
|
|
||||||
const SPANISH_SEASONS: Record<Season, string> = {
|
|
||||||
[Season.SPRING]: 'Primavera',
|
|
||||||
[Season.SUMMER]: 'Verano',
|
|
||||||
[Season.FALL]: 'Otoño',
|
|
||||||
[Season.WINTER]: 'Invierno',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEASON_COLORS: Record<Season, string> = {
|
|
||||||
[Season.SPRING]: '#22c55e', // Green
|
|
||||||
[Season.SUMMER]: '#f59e0b', // Amber
|
|
||||||
[Season.FALL]: '#ea580c', // Orange
|
|
||||||
[Season.WINTER]: '#3b82f6', // Blue
|
|
||||||
};
|
|
||||||
|
|
||||||
const SPANISH_HOLIDAYS = [
|
|
||||||
{ month: 0, name: 'Año Nuevo' },
|
|
||||||
{ month: 0, name: 'Día de Reyes' },
|
|
||||||
{ month: 2, name: 'Semana Santa' }, // March/April
|
|
||||||
{ month: 4, name: 'Día del Trabajador' },
|
|
||||||
{ month: 7, name: 'Asunción' },
|
|
||||||
{ month: 9, name: 'Día Nacional' },
|
|
||||||
{ month: 10, name: 'Todos los Santos' },
|
|
||||||
{ month: 11, name: 'Constitución' },
|
|
||||||
{ month: 11, name: 'Navidad' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const INTENSITY_COLORS = [
|
|
||||||
'#f3f4f6', // Very low
|
|
||||||
'#dbeafe', // Low
|
|
||||||
'#bfdbfe', // Medium-low
|
|
||||||
'#93c5fd', // Medium
|
|
||||||
'#60a5fa', // Medium-high
|
|
||||||
'#3b82f6', // High
|
|
||||||
'#2563eb', // Very high
|
|
||||||
'#1d4ed8', // Extremely high
|
|
||||||
];
|
|
||||||
|
|
||||||
const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
|
|
||||||
className,
|
|
||||||
title = 'Patrones Estacionales',
|
|
||||||
seasonalPatterns = [],
|
|
||||||
selectedProduct = '',
|
|
||||||
onProductChange,
|
|
||||||
viewMode = 'circular',
|
|
||||||
onViewModeChange,
|
|
||||||
showComparison = false,
|
|
||||||
comparisonYear = new Date().getFullYear() - 1,
|
|
||||||
loading = false,
|
|
||||||
error = null,
|
|
||||||
}) => {
|
|
||||||
const [hoveredElement, setHoveredElement] = useState<any>(null);
|
|
||||||
|
|
||||||
// Get current pattern data
|
|
||||||
const currentPattern = useMemo(() => {
|
|
||||||
if (!selectedProduct || seasonalPatterns.length === 0) {
|
|
||||||
return seasonalPatterns[0] || null;
|
|
||||||
}
|
|
||||||
return seasonalPatterns.find(p => p.product_name === selectedProduct) || seasonalPatterns[0];
|
|
||||||
}, [seasonalPatterns, selectedProduct]);
|
|
||||||
|
|
||||||
// Process monthly seasonal data
|
|
||||||
const monthlyData = useMemo((): MonthlyData[] => {
|
|
||||||
if (!currentPattern) return [];
|
|
||||||
|
|
||||||
const monthlyComponent = currentPattern.seasonal_components.find(
|
|
||||||
c => c.period === SeasonalPeriod.MONTHLY
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!monthlyComponent) return [];
|
|
||||||
|
|
||||||
return SPANISH_MONTHS.map((month, index) => {
|
|
||||||
const value = monthlyComponent.pattern[index] || 0;
|
|
||||||
const strength = Math.abs(value);
|
|
||||||
|
|
||||||
// Determine season
|
|
||||||
let season: Season;
|
|
||||||
if (index >= 2 && index <= 4) season = Season.SPRING;
|
|
||||||
else if (index >= 5 && index <= 7) season = Season.SUMMER;
|
|
||||||
else if (index >= 8 && index <= 10) season = Season.FALL;
|
|
||||||
else season = Season.WINTER;
|
|
||||||
|
|
||||||
// Get holidays for this month
|
|
||||||
const holidays = SPANISH_HOLIDAYS
|
|
||||||
.filter(h => h.month === index)
|
|
||||||
.map(h => h.name);
|
|
||||||
|
|
||||||
return {
|
|
||||||
month,
|
|
||||||
value: value * 100, // Convert to percentage
|
|
||||||
strength: strength * 100,
|
|
||||||
color: SEASON_COLORS[season],
|
|
||||||
season,
|
|
||||||
holidays,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [currentPattern]);
|
|
||||||
|
|
||||||
// Process weekly data
|
|
||||||
const weeklyData = useMemo((): WeeklyData[] => {
|
|
||||||
if (!currentPattern) return [];
|
|
||||||
|
|
||||||
return currentPattern.weekly_patterns.map((pattern, index) => ({
|
|
||||||
day: SPANISH_DAYS[index] || `Día ${index + 1}`,
|
|
||||||
dayShort: SPANISH_DAYS_SHORT[index] || `D${index + 1}`,
|
|
||||||
value: pattern.average_multiplier * 100,
|
|
||||||
variance: pattern.variance * 100,
|
|
||||||
peakHours: pattern.peak_hours,
|
|
||||||
}));
|
|
||||||
}, [currentPattern]);
|
|
||||||
|
|
||||||
// Process heatmap data
|
|
||||||
const heatmapData = useMemo((): HeatmapData[] => {
|
|
||||||
if (!currentPattern) return [];
|
|
||||||
|
|
||||||
const data: HeatmapData[] = [];
|
|
||||||
const monthlyComponent = currentPattern.seasonal_components.find(
|
|
||||||
c => c.period === SeasonalPeriod.MONTHLY
|
|
||||||
);
|
|
||||||
|
|
||||||
if (monthlyComponent) {
|
|
||||||
for (let month = 0; month < 12; month++) {
|
|
||||||
for (let week = 0; week < 4; week++) {
|
|
||||||
const value = monthlyComponent.pattern[month] || 0;
|
|
||||||
const intensity = Math.min(Math.max(Math.abs(value) * 8, 0), 7); // 0-7 scale
|
|
||||||
|
|
||||||
const holiday = SPANISH_HOLIDAYS.find(h => h.month === month);
|
|
||||||
|
|
||||||
data.push({
|
|
||||||
month,
|
|
||||||
week,
|
|
||||||
intensity: Math.floor(intensity),
|
|
||||||
value: value * 100,
|
|
||||||
holiday: holiday?.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}, [currentPattern]);
|
|
||||||
|
|
||||||
// Custom tooltip for radial chart
|
|
||||||
const RadialTooltip = ({ active, payload }: any) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
|
||||||
|
|
||||||
const data = payload[0].payload as MonthlyData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3 max-w-sm">
|
|
||||||
<h4 className="font-semibold text-text-primary mb-2">{data.month}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Estación:</span>
|
|
||||||
<Badge variant="ghost" size="sm" style={{ color: data.color }}>
|
|
||||||
{SPANISH_SEASONS[data.season]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Variación:</span>
|
|
||||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Intensidad:</span>
|
|
||||||
<span className="font-medium">{data.strength.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
{data.holidays.length > 0 && (
|
|
||||||
<div className="mt-2 pt-2 border-t border-border-secondary">
|
|
||||||
<div className="text-text-secondary text-sm mb-1">Festividades:</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{data.holidays.map(holiday => (
|
|
||||||
<Badge key={holiday} variant="outlined" size="sm">
|
|
||||||
{holiday}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Circular view (Radial chart)
|
|
||||||
const renderCircularView = () => (
|
|
||||||
<div style={{ width: '100%', height: 400 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<RadialBarChart
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius="60%"
|
|
||||||
outerRadius="90%"
|
|
||||||
data={monthlyData}
|
|
||||||
startAngle={90}
|
|
||||||
endAngle={450}
|
|
||||||
>
|
|
||||||
<RadialBar
|
|
||||||
dataKey="strength"
|
|
||||||
cornerRadius={4}
|
|
||||||
fill={(entry) => entry.color}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<RadialTooltip />} />
|
|
||||||
</RadialBarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calendar view (Bar chart by month)
|
|
||||||
const renderCalendarView = () => (
|
|
||||||
<div style={{ width: '100%', height: 400 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<ComposedChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="month"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={80}
|
|
||||||
/>
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
|
||||||
const data = payload[0].payload as MonthlyData;
|
|
||||||
return <RadialTooltip active={true} payload={[{ payload: data }]} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="value"
|
|
||||||
fill={(entry, index) => monthlyData[index]?.color || '#8884d8'}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="strength"
|
|
||||||
stroke="#ff7300"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
<ReferenceLine y={0} stroke="#666" strokeDasharray="2 2" />
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Heatmap view
|
|
||||||
const renderHeatmapView = () => (
|
|
||||||
<div className="grid grid-cols-12 gap-1 p-4">
|
|
||||||
{/* Month labels */}
|
|
||||||
<div className="col-span-12 grid grid-cols-12 gap-1 mb-2">
|
|
||||||
{SPANISH_MONTHS.map(month => (
|
|
||||||
<div key={month} className="text-xs text-center text-text-tertiary font-medium">
|
|
||||||
{month.slice(0, 3)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Heatmap grid */}
|
|
||||||
{[0, 1, 2, 3].map(week => (
|
|
||||||
<div key={week} className="col-span-12 grid grid-cols-12 gap-1">
|
|
||||||
{heatmapData
|
|
||||||
.filter(d => d.week === week)
|
|
||||||
.map((cell, monthIndex) => (
|
|
||||||
<div
|
|
||||||
key={`${cell.month}-${cell.week}`}
|
|
||||||
className="aspect-square rounded cursor-pointer transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
backgroundColor: INTENSITY_COLORS[cell.intensity],
|
|
||||||
border: hoveredElement?.month === cell.month && hoveredElement?.week === cell.week
|
|
||||||
? '2px solid #3b82f6' : '1px solid #e5e7eb'
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredElement(cell)}
|
|
||||||
onMouseLeave={() => setHoveredElement(null)}
|
|
||||||
title={`${SPANISH_MONTHS[cell.month]} S${cell.week + 1}: ${cell.value.toFixed(1)}%`}
|
|
||||||
>
|
|
||||||
{cell.holiday && (
|
|
||||||
<div className="text-xs">🎉</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="col-span-12 flex items-center justify-center gap-4 mt-4">
|
|
||||||
<span className="text-sm text-text-secondary">Baja</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{INTENSITY_COLORS.map((color, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="w-4 h-4 rounded"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-text-secondary">Alta</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trends view (Weekly patterns)
|
|
||||||
const renderTrendsView = () => (
|
|
||||||
<div style={{ width: '100%', height: 400 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<BarChart data={weeklyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="dayShort" tick={{ fontSize: 12 }} />
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
|
||||||
const data = payload[0].payload as WeeklyData;
|
|
||||||
return (
|
|
||||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3">
|
|
||||||
<h4 className="font-semibold text-text-primary mb-2">{data.day}</h4>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Multiplicador Promedio:</span>
|
|
||||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Varianza:</span>
|
|
||||||
<span className="font-medium">{data.variance.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
{data.peakHours && data.peakHours.length > 0 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Horas Pico:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{data.peakHours.map(h => `${h}:00`).join(', ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="value" fill="#8884d8" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
|
||||||
<span className="text-text-secondary">Cargando patrones estacionales...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={className} variant="outlined">
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-500 mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (!currentPattern) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-text-tertiary mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4v10a2 2 0 002 2h4a2 2 0 002-2V11m-6 0a2 2 0 012-2h4a2 2 0 012 2m-6 0h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary">No hay datos de estacionalidad disponibles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
{currentPattern && (
|
|
||||||
<Badge variant="ghost">
|
|
||||||
{currentPattern.product_name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge
|
|
||||||
variant="success"
|
|
||||||
size="sm"
|
|
||||||
title="Nivel de confianza del patrón estacional"
|
|
||||||
>
|
|
||||||
{(currentPattern.confidence_score * 100).toFixed(0)}% confianza
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Product selector */}
|
|
||||||
{onProductChange && seasonalPatterns.length > 1 && (
|
|
||||||
<Select
|
|
||||||
value={selectedProduct || seasonalPatterns[0]?.product_name || ''}
|
|
||||||
onChange={(value) => onProductChange(value)}
|
|
||||||
className="w-40"
|
|
||||||
>
|
|
||||||
{seasonalPatterns.map(pattern => (
|
|
||||||
<option key={pattern.product_name} value={pattern.product_name}>
|
|
||||||
{pattern.product_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View mode selector */}
|
|
||||||
<div className="flex bg-bg-secondary rounded-lg p-1 gap-1">
|
|
||||||
{(['circular', 'calendar', 'heatmap', 'trends'] as const).map((mode) => (
|
|
||||||
<Button
|
|
||||||
key={mode}
|
|
||||||
variant={viewMode === mode ? 'filled' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onViewModeChange?.(mode)}
|
|
||||||
title={
|
|
||||||
mode === 'circular' ? 'Vista Circular' :
|
|
||||||
mode === 'calendar' ? 'Vista Calendario' :
|
|
||||||
mode === 'heatmap' ? 'Mapa de Calor' :
|
|
||||||
'Vista Tendencias'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mode === 'circular' && '🔄'}
|
|
||||||
{mode === 'calendar' && '📅'}
|
|
||||||
{mode === 'heatmap' && '🔥'}
|
|
||||||
{mode === 'trends' && '📊'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Main visualization */}
|
|
||||||
<div className="min-h-[400px]">
|
|
||||||
{viewMode === 'circular' && renderCircularView()}
|
|
||||||
{viewMode === 'calendar' && renderCalendarView()}
|
|
||||||
{viewMode === 'heatmap' && renderHeatmapView()}
|
|
||||||
{viewMode === 'trends' && renderTrendsView()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Holiday effects summary */}
|
|
||||||
{currentPattern.holiday_effects && currentPattern.holiday_effects.length > 0 && (
|
|
||||||
<div className="border-t border-border-primary pt-4">
|
|
||||||
<h4 className="font-medium text-text-primary mb-3">Efectos de Festividades</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{currentPattern.holiday_effects.map((holiday, index) => (
|
|
||||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="font-medium text-text-primary text-sm">
|
|
||||||
{holiday.holiday_name}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={holiday.impact_factor > 1.2 ? 'success' : holiday.impact_factor < 0.8 ? 'danger' : 'warning'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{((holiday.impact_factor - 1) * 100).toFixed(0)}%
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-secondary space-y-1">
|
|
||||||
<div>Duración: {holiday.duration_days} días</div>
|
|
||||||
<div>Confianza: {(holiday.confidence * 100).toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pattern strength indicators */}
|
|
||||||
<div className="border-t border-border-primary pt-4">
|
|
||||||
<h4 className="font-medium text-text-primary mb-3">Intensidad de Patrones</h4>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{currentPattern.seasonal_components.map((component, index) => {
|
|
||||||
const periodLabel = {
|
|
||||||
[SeasonalPeriod.WEEKLY]: 'Semanal',
|
|
||||||
[SeasonalPeriod.MONTHLY]: 'Mensual',
|
|
||||||
[SeasonalPeriod.QUARTERLY]: 'Trimestral',
|
|
||||||
[SeasonalPeriod.YEARLY]: 'Anual',
|
|
||||||
}[component.period] || component.period;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
|
||||||
<div className="text-sm font-medium text-text-primary mb-1">
|
|
||||||
{periodLabel}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-bg-tertiary rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-color-primary h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${Math.min(component.strength * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-secondary">
|
|
||||||
{(component.strength * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SeasonalityIndicator;
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Input, Select, Badge } from '../../ui';
|
|
||||||
import type { OnboardingStepProps } from './OnboardingWizard';
|
|
||||||
|
|
||||||
interface CompanyInfo {
|
|
||||||
name: string;
|
|
||||||
type: 'artisan' | 'industrial' | 'chain' | 'mixed';
|
|
||||||
size: 'small' | 'medium' | 'large';
|
|
||||||
locations: number;
|
|
||||||
specialties: string[];
|
|
||||||
address: {
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postal_code: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
contact: {
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
website?: string;
|
|
||||||
};
|
|
||||||
established_year?: number;
|
|
||||||
tax_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BAKERY_TYPES = [
|
|
||||||
{ value: 'artisan', label: 'Artesanal', description: 'Producción tradicional y manual' },
|
|
||||||
{ value: 'industrial', label: 'Industrial', description: 'Producción automatizada a gran escala' },
|
|
||||||
{ value: 'chain', label: 'Cadena', description: 'Múltiples ubicaciones con procesos estandarizados' },
|
|
||||||
{ value: 'mixed', label: 'Mixta', description: 'Combinación de métodos artesanales e industriales' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const BAKERY_SIZES = [
|
|
||||||
{ value: 'small', label: 'Pequeña', description: '1-10 empleados' },
|
|
||||||
{ value: 'medium', label: 'Mediana', description: '11-50 empleados' },
|
|
||||||
{ value: 'large', label: 'Grande', description: '50+ empleados' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const COMMON_SPECIALTIES = [
|
|
||||||
'Pan tradicional',
|
|
||||||
'Bollería',
|
|
||||||
'Repostería',
|
|
||||||
'Pan integral',
|
|
||||||
'Pasteles',
|
|
||||||
'Productos sin gluten',
|
|
||||||
'Productos veganos',
|
|
||||||
'Pan artesanal',
|
|
||||||
'Productos de temporada',
|
|
||||||
'Catering',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CompanyInfoStep: React.FC<OnboardingStepProps> = ({
|
|
||||||
data,
|
|
||||||
onDataChange,
|
|
||||||
}) => {
|
|
||||||
const companyData: CompanyInfo = {
|
|
||||||
name: '',
|
|
||||||
type: 'artisan',
|
|
||||||
size: 'small',
|
|
||||||
locations: 1,
|
|
||||||
specialties: [],
|
|
||||||
address: {
|
|
||||||
street: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
postal_code: '',
|
|
||||||
country: 'España',
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
phone: '',
|
|
||||||
email: '',
|
|
||||||
website: '',
|
|
||||||
},
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: any) => {
|
|
||||||
onDataChange({
|
|
||||||
...companyData,
|
|
||||||
[field]: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddressChange = (field: string, value: string) => {
|
|
||||||
onDataChange({
|
|
||||||
...companyData,
|
|
||||||
address: {
|
|
||||||
...companyData.address,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContactChange = (field: string, value: string) => {
|
|
||||||
onDataChange({
|
|
||||||
...companyData,
|
|
||||||
contact: {
|
|
||||||
...companyData.contact,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSpecialtyToggle = (specialty: string) => {
|
|
||||||
const currentSpecialties = companyData.specialties || [];
|
|
||||||
const updatedSpecialties = currentSpecialties.includes(specialty)
|
|
||||||
? currentSpecialties.filter(s => s !== specialty)
|
|
||||||
: [...currentSpecialties, specialty];
|
|
||||||
|
|
||||||
handleInputChange('specialties', updatedSpecialties);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Basic Information */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Información básica
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Nombre de la panadería *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={companyData.name}
|
|
||||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
|
||||||
placeholder="Ej: Panadería San Miguel"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Tipo de panadería *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={companyData.type}
|
|
||||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{BAKERY_TYPES.map((type) => (
|
|
||||||
<option key={type.value} value={type.value}>
|
|
||||||
{type.label} - {type.description}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Tamaño de la empresa *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={companyData.size}
|
|
||||||
onChange={(e) => handleInputChange('size', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{BAKERY_SIZES.map((size) => (
|
|
||||||
<option key={size.value} value={size.value}>
|
|
||||||
{size.label} - {size.description}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Número de ubicaciones
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={companyData.locations}
|
|
||||||
onChange={(e) => handleInputChange('locations', parseInt(e.target.value) || 1)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Año de fundación
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1900"
|
|
||||||
max={new Date().getFullYear()}
|
|
||||||
value={companyData.established_year || ''}
|
|
||||||
onChange={(e) => handleInputChange('established_year', parseInt(e.target.value) || undefined)}
|
|
||||||
placeholder="Ej: 1995"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
NIF/CIF
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={companyData.tax_id || ''}
|
|
||||||
onChange={(e) => handleInputChange('tax_id', e.target.value)}
|
|
||||||
placeholder="Ej: B12345678"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Specialties */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Especialidades
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Selecciona los productos que produces habitualmente
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{COMMON_SPECIALTIES.map((specialty) => (
|
|
||||||
<button
|
|
||||||
key={specialty}
|
|
||||||
onClick={() => handleSpecialtyToggle(specialty)}
|
|
||||||
className={`p-3 text-left border rounded-lg transition-colors ${
|
|
||||||
companyData.specialties?.includes(specialty)
|
|
||||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium">{specialty}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{companyData.specialties && companyData.specialties.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Especialidades seleccionadas:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{companyData.specialties.map((specialty) => (
|
|
||||||
<Badge key={specialty} className="bg-blue-100 text-blue-800">
|
|
||||||
{specialty}
|
|
||||||
<button
|
|
||||||
onClick={() => handleSpecialtyToggle(specialty)}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Dirección principal
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Dirección *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={companyData.address.street}
|
|
||||||
onChange={(e) => handleAddressChange('street', e.target.value)}
|
|
||||||
placeholder="Calle, número, piso, puerta"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Ciudad *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={companyData.address.city}
|
|
||||||
onChange={(e) => handleAddressChange('city', e.target.value)}
|
|
||||||
placeholder="Ej: Madrid"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Provincia/Estado *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={companyData.address.state}
|
|
||||||
onChange={(e) => handleAddressChange('state', e.target.value)}
|
|
||||||
placeholder="Ej: Madrid"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Código postal *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={companyData.address.postal_code}
|
|
||||||
onChange={(e) => handleAddressChange('postal_code', e.target.value)}
|
|
||||||
placeholder="Ej: 28001"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
País *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={companyData.address.country}
|
|
||||||
onChange={(e) => handleAddressChange('country', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<option value="España">España</option>
|
|
||||||
<option value="Francia">Francia</option>
|
|
||||||
<option value="Portugal">Portugal</option>
|
|
||||||
<option value="Italia">Italia</option>
|
|
||||||
<option value="México">México</option>
|
|
||||||
<option value="Argentina">Argentina</option>
|
|
||||||
<option value="Colombia">Colombia</option>
|
|
||||||
<option value="Otro">Otro</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Information */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Información de contacto
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Teléfono *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={companyData.contact.phone}
|
|
||||||
onChange={(e) => handleContactChange('phone', e.target.value)}
|
|
||||||
placeholder="Ej: +34 911 234 567"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Email de contacto *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={companyData.contact.email}
|
|
||||||
onChange={(e) => handleContactChange('email', e.target.value)}
|
|
||||||
placeholder="contacto@panaderia.com"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Sitio web (opcional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
value={companyData.contact.website || ''}
|
|
||||||
onChange={(e) => handleContactChange('website', e.target.value)}
|
|
||||||
placeholder="https://www.panaderia.com"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-2">Resumen</h4>
|
|
||||||
<div className="text-sm text-gray-600 space-y-1">
|
|
||||||
<p><strong>Panadería:</strong> {companyData.name || 'Sin especificar'}</p>
|
|
||||||
<p><strong>Tipo:</strong> {BAKERY_TYPES.find(t => t.value === companyData.type)?.label}</p>
|
|
||||||
<p><strong>Tamaño:</strong> {BAKERY_SIZES.find(s => s.value === companyData.size)?.label}</p>
|
|
||||||
<p><strong>Especialidades:</strong> {companyData.specialties?.length || 0} seleccionadas</p>
|
|
||||||
<p><strong>Ubicaciones:</strong> {companyData.locations}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompanyInfoStep;
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { Card, Button, Input, Select, Badge } from '../../ui';
|
|
||||||
|
|
||||||
export interface OnboardingStep {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
component: React.ComponentType<OnboardingStepProps>;
|
|
||||||
isCompleted?: boolean;
|
|
||||||
isRequired?: boolean;
|
|
||||||
validation?: (data: any) => string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnboardingStepProps {
|
|
||||||
data: any;
|
|
||||||
onDataChange: (data: any) => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
isFirstStep: boolean;
|
|
||||||
isLastStep: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnboardingWizardProps {
|
|
||||||
steps: OnboardingStep[];
|
|
||||||
onComplete: (data: any) => void;
|
|
||||||
onExit?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|
||||||
steps,
|
|
||||||
onComplete,
|
|
||||||
onExit,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
||||||
const [stepData, setStepData] = useState<Record<string, any>>({});
|
|
||||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const currentStep = steps[currentStepIndex];
|
|
||||||
|
|
||||||
const updateStepData = useCallback((stepId: string, data: any) => {
|
|
||||||
setStepData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[stepId]: { ...prev[stepId], ...data }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear validation error for this step
|
|
||||||
setValidationErrors(prev => {
|
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors[stepId];
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateCurrentStep = useCallback(() => {
|
|
||||||
const step = currentStep;
|
|
||||||
const data = stepData[step.id] || {};
|
|
||||||
|
|
||||||
if (step.validation) {
|
|
||||||
const error = step.validation(data);
|
|
||||||
if (error) {
|
|
||||||
setValidationErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
[step.id]: error
|
|
||||||
}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark step as completed
|
|
||||||
setCompletedSteps(prev => new Set(prev).add(step.id));
|
|
||||||
return true;
|
|
||||||
}, [currentStep, stepData]);
|
|
||||||
|
|
||||||
const goToNextStep = useCallback(() => {
|
|
||||||
if (validateCurrentStep()) {
|
|
||||||
if (currentStepIndex < steps.length - 1) {
|
|
||||||
setCurrentStepIndex(currentStepIndex + 1);
|
|
||||||
} else {
|
|
||||||
// All steps completed, call onComplete with all data
|
|
||||||
onComplete(stepData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentStepIndex, steps.length, validateCurrentStep, onComplete, stepData]);
|
|
||||||
|
|
||||||
const goToPreviousStep = useCallback(() => {
|
|
||||||
if (currentStepIndex > 0) {
|
|
||||||
setCurrentStepIndex(currentStepIndex - 1);
|
|
||||||
}
|
|
||||||
}, [currentStepIndex]);
|
|
||||||
|
|
||||||
const goToStep = useCallback((stepIndex: number) => {
|
|
||||||
setCurrentStepIndex(stepIndex);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const calculateProgress = () => {
|
|
||||||
return (completedSteps.size / steps.length) * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStepIndicator = () => (
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const isCompleted = completedSteps.has(step.id);
|
|
||||||
const isCurrent = index === currentStepIndex;
|
|
||||||
const hasError = validationErrors[step.id];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={step.id}
|
|
||||||
className="flex items-center flex-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => goToStep(index)}
|
|
||||||
disabled={index > currentStepIndex + 1}
|
|
||||||
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors ${
|
|
||||||
isCompleted
|
|
||||||
? 'bg-green-500 text-white'
|
|
||||||
: isCurrent
|
|
||||||
? hasError
|
|
||||||
? 'bg-red-500 text-white'
|
|
||||||
: 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isCompleted ? '✓' : index + 1}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<p className={`text-sm font-medium ${isCurrent ? 'text-blue-600' : 'text-gray-600'}`}>
|
|
||||||
{step.title}
|
|
||||||
{step.isRequired && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</p>
|
|
||||||
{hasError && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">{hasError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{index < steps.length - 1 && (
|
|
||||||
<div className={`flex-1 h-0.5 mx-4 ${
|
|
||||||
isCompleted ? 'bg-green-500' : 'bg-gray-200'
|
|
||||||
}`} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderProgressBar = () => (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700">
|
|
||||||
Progreso del onboarding
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{completedSteps.size} de {steps.length} completados
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${calculateProgress()}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentStep) {
|
|
||||||
return (
|
|
||||||
<Card className={`p-8 text-center ${className}`}>
|
|
||||||
<p className="text-gray-500">No hay pasos de onboarding configurados.</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const StepComponent = currentStep.component;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`max-w-4xl mx-auto ${className}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
|
||||||
Configuración inicial
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Completa estos pasos para comenzar a usar la plataforma
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{onExit && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onExit}
|
|
||||||
className="text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
✕ Salir
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderProgressBar()}
|
|
||||||
{renderStepIndicator()}
|
|
||||||
|
|
||||||
{/* Current Step Content */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
{currentStep.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{currentStep.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StepComponent
|
|
||||||
data={stepData[currentStep.id] || {}}
|
|
||||||
onDataChange={(data) => updateStepData(currentStep.id, data)}
|
|
||||||
onNext={goToNextStep}
|
|
||||||
onPrevious={goToPreviousStep}
|
|
||||||
isFirstStep={currentStepIndex === 0}
|
|
||||||
isLastStep={currentStepIndex === steps.length - 1}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex justify-between items-center mt-6">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={goToPreviousStep}
|
|
||||||
disabled={currentStepIndex === 0}
|
|
||||||
>
|
|
||||||
← Anterior
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
Paso {currentStepIndex + 1} de {steps.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={goToNextStep}
|
|
||||||
>
|
|
||||||
{currentStepIndex === steps.length - 1 ? 'Finalizar' : 'Siguiente →'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OnboardingWizard;
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Card, Button, Input, Select, Badge } from '../../ui';
|
|
||||||
import type { OnboardingStepProps } from './OnboardingWizard';
|
|
||||||
|
|
||||||
interface SystemConfig {
|
|
||||||
timezone: string;
|
|
||||||
currency: string;
|
|
||||||
language: string;
|
|
||||||
date_format: string;
|
|
||||||
number_format: string;
|
|
||||||
working_hours: {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
days: number[];
|
|
||||||
};
|
|
||||||
notifications: {
|
|
||||||
email_enabled: boolean;
|
|
||||||
sms_enabled: boolean;
|
|
||||||
push_enabled: boolean;
|
|
||||||
alert_preferences: string[];
|
|
||||||
};
|
|
||||||
integrations: {
|
|
||||||
pos_system?: string;
|
|
||||||
accounting_software?: string;
|
|
||||||
payment_provider?: string;
|
|
||||||
};
|
|
||||||
features: {
|
|
||||||
inventory_management: boolean;
|
|
||||||
production_planning: boolean;
|
|
||||||
sales_analytics: boolean;
|
|
||||||
customer_management: boolean;
|
|
||||||
financial_reporting: boolean;
|
|
||||||
quality_control: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIMEZONES = [
|
|
||||||
{ value: 'Europe/Madrid', label: 'Madrid (GMT+1)' },
|
|
||||||
{ value: 'Europe/London', label: 'London (GMT+0)' },
|
|
||||||
{ value: 'Europe/Paris', label: 'Paris (GMT+1)' },
|
|
||||||
{ value: 'America/New_York', label: 'New York (GMT-5)' },
|
|
||||||
{ value: 'America/Los_Angeles', label: 'Los Angeles (GMT-8)' },
|
|
||||||
{ value: 'America/Mexico_City', label: 'Mexico City (GMT-6)' },
|
|
||||||
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (GMT-3)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CURRENCIES = [
|
|
||||||
{ value: 'EUR', label: 'Euro (€)', symbol: '€' },
|
|
||||||
{ value: 'USD', label: 'US Dollar ($)', symbol: '$' },
|
|
||||||
{ value: 'GBP', label: 'British Pound (£)', symbol: '£' },
|
|
||||||
{ value: 'MXN', label: 'Mexican Peso ($)', symbol: '$' },
|
|
||||||
{ value: 'ARS', label: 'Argentine Peso ($)', symbol: '$' },
|
|
||||||
{ value: 'COP', label: 'Colombian Peso ($)', symbol: '$' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const LANGUAGES = [
|
|
||||||
{ value: 'es', label: 'Español' },
|
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
{ value: 'fr', label: 'Français' },
|
|
||||||
{ value: 'pt', label: 'Português' },
|
|
||||||
{ value: 'it', label: 'Italiano' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DAYS_OF_WEEK = [
|
|
||||||
{ value: 1, label: 'Lunes', short: 'L' },
|
|
||||||
{ value: 2, label: 'Martes', short: 'M' },
|
|
||||||
{ value: 3, label: 'Miércoles', short: 'X' },
|
|
||||||
{ value: 4, label: 'Jueves', short: 'J' },
|
|
||||||
{ value: 5, label: 'Viernes', short: 'V' },
|
|
||||||
{ value: 6, label: 'Sábado', short: 'S' },
|
|
||||||
{ value: 0, label: 'Domingo', short: 'D' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ALERT_TYPES = [
|
|
||||||
{ value: 'low_stock', label: 'Stock bajo', description: 'Cuando los ingredientes están por debajo del mínimo' },
|
|
||||||
{ value: 'production_delays', label: 'Retrasos de producción', description: 'Cuando los lotes se retrasan' },
|
|
||||||
{ value: 'quality_issues', label: 'Problemas de calidad', description: 'Cuando se detectan problemas de calidad' },
|
|
||||||
{ value: 'financial_targets', label: 'Objetivos financieros', description: 'Cuando se alcanzan o no se cumplen objetivos' },
|
|
||||||
{ value: 'equipment_maintenance', label: 'Mantenimiento de equipos', description: 'Recordatorios de mantenimiento' },
|
|
||||||
{ value: 'food_safety', label: 'Seguridad alimentaria', description: 'Alertas relacionadas con seguridad alimentaria' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FEATURES = [
|
|
||||||
{
|
|
||||||
key: 'inventory_management',
|
|
||||||
title: 'Gestión de inventario',
|
|
||||||
description: 'Control de stock, ingredientes y materias primas',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'production_planning',
|
|
||||||
title: 'Planificación de producción',
|
|
||||||
description: 'Programación de lotes y gestión de recetas',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sales_analytics',
|
|
||||||
title: 'Analytics de ventas',
|
|
||||||
description: 'Reportes y análisis de ventas y tendencias',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'customer_management',
|
|
||||||
title: 'Gestión de clientes',
|
|
||||||
description: 'Base de datos de clientes y programa de fidelización',
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'financial_reporting',
|
|
||||||
title: 'Reportes financieros',
|
|
||||||
description: 'Análisis de costos, márgenes y rentabilidad',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quality_control',
|
|
||||||
title: 'Control de calidad',
|
|
||||||
description: 'Seguimiento de calidad y estándares',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const SystemSetupStep: React.FC<OnboardingStepProps> = ({
|
|
||||||
data,
|
|
||||||
onDataChange,
|
|
||||||
}) => {
|
|
||||||
const systemData: SystemConfig = {
|
|
||||||
timezone: 'Europe/Madrid',
|
|
||||||
currency: 'EUR',
|
|
||||||
language: 'es',
|
|
||||||
date_format: 'DD/MM/YYYY',
|
|
||||||
number_format: 'European',
|
|
||||||
working_hours: {
|
|
||||||
start: '06:00',
|
|
||||||
end: '20:00',
|
|
||||||
days: [1, 2, 3, 4, 5, 6], // Monday to Saturday
|
|
||||||
},
|
|
||||||
notifications: {
|
|
||||||
email_enabled: true,
|
|
||||||
sms_enabled: false,
|
|
||||||
push_enabled: true,
|
|
||||||
alert_preferences: ['low_stock', 'production_delays', 'quality_issues'],
|
|
||||||
},
|
|
||||||
integrations: {},
|
|
||||||
features: {
|
|
||||||
inventory_management: true,
|
|
||||||
production_planning: true,
|
|
||||||
sales_analytics: true,
|
|
||||||
customer_management: false,
|
|
||||||
financial_reporting: true,
|
|
||||||
quality_control: true,
|
|
||||||
},
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: any) => {
|
|
||||||
onDataChange({
|
|
||||||
...systemData,
|
|
||||||
[field]: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWorkingHoursChange = (field: string, value: any) => {
|
|
||||||
onDataChange({
|
|
||||||
...systemData,
|
|
||||||
working_hours: {
|
|
||||||
...systemData.working_hours,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationChange = (field: string, value: any) => {
|
|
||||||
onDataChange({
|
|
||||||
...systemData,
|
|
||||||
notifications: {
|
|
||||||
...systemData.notifications,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIntegrationChange = (field: string, value: string) => {
|
|
||||||
onDataChange({
|
|
||||||
...systemData,
|
|
||||||
integrations: {
|
|
||||||
...systemData.integrations,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeatureToggle = (feature: string) => {
|
|
||||||
onDataChange({
|
|
||||||
...systemData,
|
|
||||||
features: {
|
|
||||||
...systemData.features,
|
|
||||||
[feature]: !systemData.features[feature as keyof typeof systemData.features],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleWorkingDay = (day: number) => {
|
|
||||||
const currentDays = systemData.working_hours.days;
|
|
||||||
const updatedDays = currentDays.includes(day)
|
|
||||||
? currentDays.filter(d => d !== day)
|
|
||||||
: [...currentDays, day].sort();
|
|
||||||
|
|
||||||
handleWorkingHoursChange('days', updatedDays);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAlertPreference = (alert: string) => {
|
|
||||||
const current = systemData.notifications.alert_preferences;
|
|
||||||
const updated = current.includes(alert)
|
|
||||||
? current.filter(a => a !== alert)
|
|
||||||
: [...current, alert];
|
|
||||||
|
|
||||||
handleNotificationChange('alert_preferences', updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Regional Settings */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Configuración regional
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Zona horaria *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.timezone}
|
|
||||||
onChange={(e) => handleInputChange('timezone', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{TIMEZONES.map((tz) => (
|
|
||||||
<option key={tz.value} value={tz.value}>
|
|
||||||
{tz.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Moneda *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.currency}
|
|
||||||
onChange={(e) => handleInputChange('currency', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{CURRENCIES.map((currency) => (
|
|
||||||
<option key={currency.value} value={currency.value}>
|
|
||||||
{currency.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Idioma *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.language}
|
|
||||||
onChange={(e) => handleInputChange('language', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{LANGUAGES.map((lang) => (
|
|
||||||
<option key={lang.value} value={lang.value}>
|
|
||||||
{lang.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Formato de fecha *
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.date_format}
|
|
||||||
onChange={(e) => handleInputChange('date_format', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Working Hours */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Horario de trabajo
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Hora de apertura *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={systemData.working_hours.start}
|
|
||||||
onChange={(e) => handleWorkingHoursChange('start', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Hora de cierre *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={systemData.working_hours.end}
|
|
||||||
onChange={(e) => handleWorkingHoursChange('end', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Días de operación *
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DAYS_OF_WEEK.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day.value}
|
|
||||||
onClick={() => toggleWorkingDay(day.value)}
|
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
systemData.working_hours.days.includes(day.value)
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{day.short} - {day.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Módulos y características
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Selecciona las características que quieres activar. Podrás cambiar esto más tarde.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{FEATURES.map((feature) => (
|
|
||||||
<Card
|
|
||||||
key={feature.key}
|
|
||||||
className={`p-4 cursor-pointer transition-colors ${
|
|
||||||
systemData.features[feature.key as keyof typeof systemData.features]
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleFeatureToggle(feature.key)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-gray-900">{feature.title}</h4>
|
|
||||||
{feature.recommended && (
|
|
||||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
|
||||||
Recomendado
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
|
||||||
systemData.features[feature.key as keyof typeof systemData.features]
|
|
||||||
? 'border-blue-500 bg-blue-500'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}>
|
|
||||||
{systemData.features[feature.key as keyof typeof systemData.features] && (
|
|
||||||
<span className="text-white text-xs">✓</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Notificaciones
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={systemData.notifications.email_enabled}
|
|
||||||
onChange={(e) => handleNotificationChange('email_enabled', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
/>
|
|
||||||
Notificaciones por email
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={systemData.notifications.push_enabled}
|
|
||||||
onChange={(e) => handleNotificationChange('push_enabled', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
/>
|
|
||||||
Notificaciones push
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={systemData.notifications.sms_enabled}
|
|
||||||
onChange={(e) => handleNotificationChange('sms_enabled', e.target.checked)}
|
|
||||||
className="mr-2 rounded"
|
|
||||||
/>
|
|
||||||
Notificaciones por SMS
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Tipos de alertas que deseas recibir
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{ALERT_TYPES.map((alert) => (
|
|
||||||
<div
|
|
||||||
key={alert.value}
|
|
||||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
|
||||||
systemData.notifications.alert_preferences.includes(alert.value)
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleAlertPreference(alert.value)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h5 className="font-medium text-gray-900">{alert.label}</h5>
|
|
||||||
<p className="text-sm text-gray-600">{alert.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
|
||||||
systemData.notifications.alert_preferences.includes(alert.value)
|
|
||||||
? 'border-blue-500 bg-blue-500'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}>
|
|
||||||
{systemData.notifications.alert_preferences.includes(alert.value) && (
|
|
||||||
<span className="text-white text-xs">✓</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Integrations */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
|
|
||||||
Integraciones (opcional)
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Estas integraciones se pueden configurar más tarde desde el panel de administración.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Sistema POS
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.integrations.pos_system || ''}
|
|
||||||
onChange={(e) => handleIntegrationChange('pos_system', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar...</option>
|
|
||||||
<option value="square">Square</option>
|
|
||||||
<option value="shopify">Shopify POS</option>
|
|
||||||
<option value="toast">Toast</option>
|
|
||||||
<option value="lightspeed">Lightspeed</option>
|
|
||||||
<option value="other">Otro</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Software de contabilidad
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.integrations.accounting_software || ''}
|
|
||||||
onChange={(e) => handleIntegrationChange('accounting_software', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar...</option>
|
|
||||||
<option value="sage">Sage</option>
|
|
||||||
<option value="contaplus">ContaPlus</option>
|
|
||||||
<option value="a3">A3 Software</option>
|
|
||||||
<option value="quickbooks">QuickBooks</option>
|
|
||||||
<option value="other">Otro</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Proveedor de pagos
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={systemData.integrations.payment_provider || ''}
|
|
||||||
onChange={(e) => handleIntegrationChange('payment_provider', e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar...</option>
|
|
||||||
<option value="stripe">Stripe</option>
|
|
||||||
<option value="paypal">PayPal</option>
|
|
||||||
<option value="redsys">Redsys</option>
|
|
||||||
<option value="bizum">Bizum</option>
|
|
||||||
<option value="other">Otro</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-2">Configuración seleccionada</h4>
|
|
||||||
<div className="text-sm text-gray-600 space-y-1">
|
|
||||||
<p><strong>Zona horaria:</strong> {TIMEZONES.find(tz => tz.value === systemData.timezone)?.label}</p>
|
|
||||||
<p><strong>Moneda:</strong> {CURRENCIES.find(c => c.value === systemData.currency)?.label}</p>
|
|
||||||
<p><strong>Horario:</strong> {systemData.working_hours.start} - {systemData.working_hours.end}</p>
|
|
||||||
<p><strong>Días operativos:</strong> {systemData.working_hours.days.length} días por semana</p>
|
|
||||||
<p><strong>Módulos activados:</strong> {Object.values(systemData.features).filter(Boolean).length} de {Object.keys(systemData.features).length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SystemSetupStep;
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
|
||||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../services/api/production.service';
|
|
||||||
import type { ProductionBatch, QualityCheck } from '../../../types/production.types';
|
|
||||||
|
|
||||||
interface BatchTrackerProps {
|
|
||||||
className?: string;
|
|
||||||
batchId?: string;
|
|
||||||
onStageUpdate?: (batch: ProductionBatch, newStage: ProductionStage) => void;
|
|
||||||
onQualityCheckRequired?: (batch: ProductionBatch, stage: ProductionStage) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductionStage {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
spanishName: string;
|
|
||||||
icon: string;
|
|
||||||
estimatedMinutes: number;
|
|
||||||
requiresQualityCheck: boolean;
|
|
||||||
criticalControlPoint: boolean;
|
|
||||||
completedAt?: string;
|
|
||||||
notes?: string;
|
|
||||||
nextStages: string[];
|
|
||||||
temperature?: {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
unit: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRODUCTION_STAGES: Record<string, ProductionStage> = {
|
|
||||||
mixing: {
|
|
||||||
id: 'mixing',
|
|
||||||
name: 'Mixing',
|
|
||||||
spanishName: 'Amasado',
|
|
||||||
icon: '🥄',
|
|
||||||
estimatedMinutes: 15,
|
|
||||||
requiresQualityCheck: true,
|
|
||||||
criticalControlPoint: true,
|
|
||||||
nextStages: ['resting'],
|
|
||||||
temperature: { min: 22, max: 26, unit: '°C' },
|
|
||||||
},
|
|
||||||
resting: {
|
|
||||||
id: 'resting',
|
|
||||||
name: 'Resting',
|
|
||||||
spanishName: 'Reposo',
|
|
||||||
icon: '⏰',
|
|
||||||
estimatedMinutes: 60,
|
|
||||||
requiresQualityCheck: false,
|
|
||||||
criticalControlPoint: false,
|
|
||||||
nextStages: ['shaping', 'fermentation'],
|
|
||||||
},
|
|
||||||
shaping: {
|
|
||||||
id: 'shaping',
|
|
||||||
name: 'Shaping',
|
|
||||||
spanishName: 'Formado',
|
|
||||||
icon: '✋',
|
|
||||||
estimatedMinutes: 20,
|
|
||||||
requiresQualityCheck: true,
|
|
||||||
criticalControlPoint: false,
|
|
||||||
nextStages: ['fermentation'],
|
|
||||||
},
|
|
||||||
fermentation: {
|
|
||||||
id: 'fermentation',
|
|
||||||
name: 'Fermentation',
|
|
||||||
spanishName: 'Fermentado',
|
|
||||||
icon: '🫧',
|
|
||||||
estimatedMinutes: 120,
|
|
||||||
requiresQualityCheck: true,
|
|
||||||
criticalControlPoint: true,
|
|
||||||
nextStages: ['baking'],
|
|
||||||
temperature: { min: 28, max: 32, unit: '°C' },
|
|
||||||
},
|
|
||||||
baking: {
|
|
||||||
id: 'baking',
|
|
||||||
name: 'Baking',
|
|
||||||
spanishName: 'Horneado',
|
|
||||||
icon: '🔥',
|
|
||||||
estimatedMinutes: 45,
|
|
||||||
requiresQualityCheck: true,
|
|
||||||
criticalControlPoint: true,
|
|
||||||
nextStages: ['cooling'],
|
|
||||||
temperature: { min: 180, max: 220, unit: '°C' },
|
|
||||||
},
|
|
||||||
cooling: {
|
|
||||||
id: 'cooling',
|
|
||||||
name: 'Cooling',
|
|
||||||
spanishName: 'Enfriado',
|
|
||||||
icon: '❄️',
|
|
||||||
estimatedMinutes: 90,
|
|
||||||
requiresQualityCheck: false,
|
|
||||||
criticalControlPoint: false,
|
|
||||||
nextStages: ['packaging'],
|
|
||||||
temperature: { min: 18, max: 25, unit: '°C' },
|
|
||||||
},
|
|
||||||
packaging: {
|
|
||||||
id: 'packaging',
|
|
||||||
name: 'Packaging',
|
|
||||||
spanishName: 'Empaquetado',
|
|
||||||
icon: '📦',
|
|
||||||
estimatedMinutes: 15,
|
|
||||||
requiresQualityCheck: true,
|
|
||||||
criticalControlPoint: false,
|
|
||||||
nextStages: ['completed'],
|
|
||||||
},
|
|
||||||
completed: {
|
|
||||||
id: 'completed',
|
|
||||||
name: 'Completed',
|
|
||||||
spanishName: 'Completado',
|
|
||||||
icon: '✅',
|
|
||||||
estimatedMinutes: 0,
|
|
||||||
requiresQualityCheck: false,
|
|
||||||
criticalControlPoint: false,
|
|
||||||
nextStages: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
|
||||||
[ProductionBatchStatus.PLANNED]: 'bg-blue-100 text-blue-800 border-blue-200',
|
|
||||||
[ProductionBatchStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
||||||
[ProductionBatchStatus.COMPLETED]: 'bg-green-100 text-green-800 border-green-200',
|
|
||||||
[ProductionBatchStatus.CANCELLED]: 'bg-red-100 text-red-800 border-red-200',
|
|
||||||
[ProductionBatchStatus.ON_HOLD]: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_COLORS = {
|
|
||||||
[ProductionPriority.LOW]: 'bg-gray-100 text-gray-800',
|
|
||||||
[ProductionPriority.NORMAL]: 'bg-blue-100 text-blue-800',
|
|
||||||
[ProductionPriority.HIGH]: 'bg-orange-100 text-orange-800',
|
|
||||||
[ProductionPriority.URGENT]: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BatchTracker: React.FC<BatchTrackerProps> = ({
|
|
||||||
className = '',
|
|
||||||
batchId,
|
|
||||||
onStageUpdate,
|
|
||||||
onQualityCheckRequired,
|
|
||||||
}) => {
|
|
||||||
const [batches, setBatches] = useState<ProductionBatchResponse[]>([]);
|
|
||||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [currentStage, setCurrentStage] = useState<string>('mixing');
|
|
||||||
const [stageNotes, setStageNotes] = useState<Record<string, string>>({});
|
|
||||||
const [isStageModalOpen, setIsStageModalOpen] = useState(false);
|
|
||||||
const [selectedStageForUpdate, setSelectedStageForUpdate] = useState<ProductionStage | null>(null);
|
|
||||||
const [alerts, setAlerts] = useState<Array<{
|
|
||||||
id: string;
|
|
||||||
batchId: string;
|
|
||||||
stage: string;
|
|
||||||
type: 'overdue' | 'temperature' | 'quality' | 'equipment';
|
|
||||||
message: string;
|
|
||||||
severity: 'low' | 'medium' | 'high';
|
|
||||||
timestamp: string;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
const loadBatches = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await productionService.getProductionBatches({
|
|
||||||
status: ProductionBatchStatus.IN_PROGRESS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setBatches(response.data.items || []);
|
|
||||||
|
|
||||||
if (batchId) {
|
|
||||||
const specificBatch = response.data.items.find(b => b.id === batchId);
|
|
||||||
if (specificBatch) {
|
|
||||||
setSelectedBatch(specificBatch);
|
|
||||||
}
|
|
||||||
} else if (response.data.items.length > 0) {
|
|
||||||
setSelectedBatch(response.data.items[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading batches:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [batchId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadBatches();
|
|
||||||
|
|
||||||
// Mock alerts for demonstration
|
|
||||||
setAlerts([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
batchId: 'batch-1',
|
|
||||||
stage: 'fermentation',
|
|
||||||
type: 'overdue',
|
|
||||||
message: 'El fermentado ha superado el tiempo estimado en 30 minutos',
|
|
||||||
severity: 'medium',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
batchId: 'batch-2',
|
|
||||||
stage: 'baking',
|
|
||||||
type: 'temperature',
|
|
||||||
message: 'Temperatura del horno fuera del rango óptimo (185°C)',
|
|
||||||
severity: 'high',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, [loadBatches]);
|
|
||||||
|
|
||||||
const getCurrentStageInfo = (batch: ProductionBatchResponse): { stage: ProductionStage; progress: number } => {
|
|
||||||
// This would typically come from the batch data
|
|
||||||
// For demo purposes, we'll simulate based on batch status
|
|
||||||
let stageId = 'mixing';
|
|
||||||
let progress = 0;
|
|
||||||
|
|
||||||
if (batch.status === ProductionBatchStatus.IN_PROGRESS) {
|
|
||||||
// Simulate current stage based on time elapsed
|
|
||||||
const startTime = new Date(batch.actual_start_date || batch.planned_start_date);
|
|
||||||
const now = new Date();
|
|
||||||
const elapsedMinutes = (now.getTime() - startTime.getTime()) / (1000 * 60);
|
|
||||||
|
|
||||||
let cumulativeTime = 0;
|
|
||||||
const stageKeys = Object.keys(PRODUCTION_STAGES).slice(0, -1); // Exclude completed
|
|
||||||
|
|
||||||
for (const key of stageKeys) {
|
|
||||||
const stage = PRODUCTION_STAGES[key];
|
|
||||||
cumulativeTime += stage.estimatedMinutes;
|
|
||||||
if (elapsedMinutes <= cumulativeTime) {
|
|
||||||
stageId = key;
|
|
||||||
const stageStartTime = cumulativeTime - stage.estimatedMinutes;
|
|
||||||
progress = Math.min(100, ((elapsedMinutes - stageStartTime) / stage.estimatedMinutes) * 100);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
stage: PRODUCTION_STAGES[stageId],
|
|
||||||
progress: Math.max(0, progress),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTimeRemaining = (batch: ProductionBatchResponse, stage: ProductionStage): string => {
|
|
||||||
const startTime = new Date(batch.actual_start_date || batch.planned_start_date);
|
|
||||||
const now = new Date();
|
|
||||||
const elapsedMinutes = (now.getTime() - startTime.getTime()) / (1000 * 60);
|
|
||||||
|
|
||||||
// Calculate when this stage should complete based on cumulative time
|
|
||||||
let cumulativeTime = 0;
|
|
||||||
const stageKeys = Object.keys(PRODUCTION_STAGES);
|
|
||||||
const currentStageIndex = stageKeys.indexOf(stage.id);
|
|
||||||
|
|
||||||
for (let i = 0; i <= currentStageIndex; i++) {
|
|
||||||
cumulativeTime += PRODUCTION_STAGES[stageKeys[i]].estimatedMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingMinutes = Math.max(0, cumulativeTime - elapsedMinutes);
|
|
||||||
const hours = Math.floor(remainingMinutes / 60);
|
|
||||||
const minutes = Math.floor(remainingMinutes % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m restantes`;
|
|
||||||
} else {
|
|
||||||
return `${minutes}m restantes`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStage = async (batchId: string, newStage: string, notes?: string) => {
|
|
||||||
try {
|
|
||||||
// This would update the batch stage in the backend
|
|
||||||
const updatedBatch = batches.find(b => b.id === batchId);
|
|
||||||
if (updatedBatch && onStageUpdate) {
|
|
||||||
onStageUpdate(updatedBatch as unknown as ProductionBatch, PRODUCTION_STAGES[newStage]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
if (notes) {
|
|
||||||
setStageNotes(prev => ({
|
|
||||||
...prev,
|
|
||||||
[`${batchId}-${newStage}`]: notes,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadBatches();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating stage:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQualityCheck = (batch: ProductionBatchResponse, stage: ProductionStage) => {
|
|
||||||
if (onQualityCheckRequired) {
|
|
||||||
onQualityCheckRequired(batch as unknown as ProductionBatch, stage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStageProgress = (batch: ProductionBatchResponse) => {
|
|
||||||
const { stage: currentStage, progress } = getCurrentStageInfo(batch);
|
|
||||||
const stages = Object.values(PRODUCTION_STAGES).slice(0, -1); // Exclude completed
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold text-lg">Progreso del lote</h3>
|
|
||||||
<Badge className={STATUS_COLORS[batch.status]}>
|
|
||||||
{batch.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'}
|
|
||||||
{batch.status === ProductionBatchStatus.PLANNED && 'Planificado'}
|
|
||||||
{batch.status === ProductionBatchStatus.COMPLETED && 'Completado'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{stages.map((stage, index) => {
|
|
||||||
const isActive = stage.id === currentStage.id;
|
|
||||||
const isCompleted = stages.findIndex(s => s.id === currentStage.id) > index;
|
|
||||||
const isOverdue = false; // Would be calculated based on actual timing
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={stage.id}
|
|
||||||
className={`p-4 border-2 transition-all cursor-pointer hover:shadow-md ${
|
|
||||||
isActive
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: isCompleted
|
|
||||||
? 'border-green-500 bg-green-50'
|
|
||||||
: 'border-gray-200 bg-white'
|
|
||||||
} ${isOverdue ? 'border-red-500 bg-red-50' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedStageForUpdate(stage);
|
|
||||||
setIsStageModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">{stage.icon}</span>
|
|
||||||
<span className="font-medium text-sm">{stage.spanishName}</span>
|
|
||||||
</div>
|
|
||||||
{stage.criticalControlPoint && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
PCC
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isActive && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-xs text-gray-600">
|
|
||||||
<span>Progreso</span>
|
|
||||||
<span>{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
{getTimeRemaining(batch, stage)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCompleted && (
|
|
||||||
<div className="flex items-center text-green-600 text-sm">
|
|
||||||
<span className="mr-1">✓</span>
|
|
||||||
Completado
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isActive && !isCompleted && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
~{stage.estimatedMinutes}min
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.temperature && isActive && (
|
|
||||||
<p className="text-xs text-gray-600 mt-1">
|
|
||||||
🌡️ {stage.temperature.min}-{stage.temperature.max}{stage.temperature.unit}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBatchDetails = (batch: ProductionBatchResponse) => (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-gray-900">{batch.recipe?.name || 'Producto'}</h4>
|
|
||||||
<p className="text-sm text-gray-600">Lote #{batch.batch_number}</p>
|
|
||||||
<Badge className={PRIORITY_COLORS[batch.priority]} size="sm">
|
|
||||||
{batch.priority === ProductionPriority.LOW && 'Baja'}
|
|
||||||
{batch.priority === ProductionPriority.NORMAL && 'Normal'}
|
|
||||||
{batch.priority === ProductionPriority.HIGH && 'Alta'}
|
|
||||||
{batch.priority === ProductionPriority.URGENT && 'Urgente'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Cantidad planificada</p>
|
|
||||||
<p className="font-semibold">{batch.planned_quantity} unidades</p>
|
|
||||||
{batch.actual_quantity && (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">Cantidad real</p>
|
|
||||||
<p className="font-semibold">{batch.actual_quantity} unidades</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Inicio planificado</p>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{new Date(batch.planned_start_date).toLocaleString('es-ES')}
|
|
||||||
</p>
|
|
||||||
{batch.actual_start_date && (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">Inicio real</p>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{new Date(batch.actual_start_date).toLocaleString('es-ES')}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{batch.notes && (
|
|
||||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-700">{batch.notes}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderAlerts = () => (
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
|
||||||
🚨 Alertas activas
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{alerts.length === 0 ? (
|
|
||||||
<p className="text-gray-500 text-center py-4">No hay alertas activas</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{alerts.map((alert) => (
|
|
||||||
<div
|
|
||||||
key={alert.id}
|
|
||||||
className={`p-3 rounded-lg border-l-4 ${
|
|
||||||
alert.severity === 'high'
|
|
||||||
? 'bg-red-50 border-red-500'
|
|
||||||
: alert.severity === 'medium'
|
|
||||||
? 'bg-yellow-50 border-yellow-500'
|
|
||||||
: 'bg-blue-50 border-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">
|
|
||||||
Lote #{batches.find(b => b.id === alert.batchId)?.batch_number} - {PRODUCTION_STAGES[alert.stage]?.spanishName}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-700 mt-1">{alert.message}</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
className={
|
|
||||||
alert.severity === 'high'
|
|
||||||
? 'bg-red-100 text-red-800'
|
|
||||||
: alert.severity === 'medium'
|
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
|
||||||
: 'bg-blue-100 text-blue-800'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{alert.severity === 'high' && 'Alta'}
|
|
||||||
{alert.severity === 'medium' && 'Media'}
|
|
||||||
{alert.severity === 'low' && 'Baja'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
{new Date(alert.timestamp).toLocaleTimeString('es-ES')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Seguimiento de Lotes</h2>
|
|
||||||
<p className="text-gray-600">Rastrea el progreso de los lotes a través de las etapas de producción</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedBatch && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedBatch.id}
|
|
||||||
onChange={(e) => {
|
|
||||||
const batch = batches.find(b => b.id === e.target.value);
|
|
||||||
if (batch) setSelectedBatch(batch);
|
|
||||||
}}
|
|
||||||
className="w-64"
|
|
||||||
>
|
|
||||||
{batches.map((batch) => (
|
|
||||||
<option key={batch.id} value={batch.id}>
|
|
||||||
Lote #{batch.batch_number} - {batch.recipe?.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={loadBatches}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Actualizando...' : 'Actualizar'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{selectedBatch ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{renderBatchDetails(selectedBatch)}
|
|
||||||
{renderStageProgress(selectedBatch)}
|
|
||||||
{renderAlerts()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<p className="text-gray-500">No hay lotes en producción actualmente</p>
|
|
||||||
<Button variant="primary" className="mt-4">
|
|
||||||
Ver todos los lotes
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stage Update Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isStageModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsStageModalOpen(false);
|
|
||||||
setSelectedStageForUpdate(null);
|
|
||||||
}}
|
|
||||||
title={`${selectedStageForUpdate?.spanishName} - Lote #${selectedBatch?.batch_number}`}
|
|
||||||
>
|
|
||||||
{selectedStageForUpdate && selectedBatch && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium mb-2 flex items-center gap-2">
|
|
||||||
<span className="text-xl">{selectedStageForUpdate.icon}</span>
|
|
||||||
{selectedStageForUpdate.spanishName}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-2">
|
|
||||||
Duración estimada: {selectedStageForUpdate.estimatedMinutes} minutos
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{selectedStageForUpdate.temperature && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
🌡️ Temperatura: {selectedStageForUpdate.temperature.min}-{selectedStageForUpdate.temperature.max}{selectedStageForUpdate.temperature.unit}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedStageForUpdate.criticalControlPoint && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 mt-2">
|
|
||||||
Punto Crítico de Control (PCC)
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedStageForUpdate.requiresQualityCheck && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 p-3 rounded-lg">
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
⚠️ Esta etapa requiere control de calidad antes de continuar
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => handleQualityCheck(selectedBatch, selectedStageForUpdate)}
|
|
||||||
>
|
|
||||||
Realizar control de calidad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Notas de la etapa
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
as="textarea"
|
|
||||||
rows={3}
|
|
||||||
placeholder="Añadir observaciones o notas sobre esta etapa..."
|
|
||||||
value={stageNotes[`${selectedBatch.id}-${selectedStageForUpdate.id}`] || ''}
|
|
||||||
onChange={(e) => setStageNotes(prev => ({
|
|
||||||
...prev,
|
|
||||||
[`${selectedBatch.id}-${selectedStageForUpdate.id}`]: e.target.value,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsStageModalOpen(false)}
|
|
||||||
>
|
|
||||||
Cerrar
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
const notes = stageNotes[`${selectedBatch.id}-${selectedStageForUpdate.id}`];
|
|
||||||
updateStage(selectedBatch.id, selectedStageForUpdate.id, notes);
|
|
||||||
setIsStageModalOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Marcar como completado
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Lotes activos</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-600">{batches.length}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">📊</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Alertas activas</p>
|
|
||||||
<p className="text-2xl font-bold text-red-600">{alerts.length}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">🚨</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">En horneado</p>
|
|
||||||
<p className="text-2xl font-bold text-orange-600">3</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">🔥</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Completados hoy</p>
|
|
||||||
<p className="text-2xl font-bold text-green-600">12</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">✅</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BatchTracker;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '../BatchTracker';
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import { Card, Button, Badge, Select, DatePicker, Input, Modal, Table } from '../../ui';
|
|
||||||
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../services/api/production.service';
|
|
||||||
import type { ProductionSchedule as ProductionScheduleType, ProductionBatch, EquipmentReservation, StaffAssignment } from '../../../types/production.types';
|
|
||||||
|
|
||||||
interface ProductionScheduleProps {
|
|
||||||
className?: string;
|
|
||||||
onBatchSelected?: (batch: ProductionBatch) => void;
|
|
||||||
onScheduleUpdate?: (schedule: ProductionScheduleEntry) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimeSlot {
|
|
||||||
time: string;
|
|
||||||
capacity: number;
|
|
||||||
utilized: number;
|
|
||||||
available: number;
|
|
||||||
batches: ProductionScheduleEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CapacityResource {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'oven' | 'mixer' | 'staff';
|
|
||||||
capacity: number;
|
|
||||||
slots: TimeSlot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRODUCT_TYPE_COLORS = {
|
|
||||||
pan: 'bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20 text-[var(--color-warning)]',
|
|
||||||
bolleria: 'bg-[var(--color-primary)]/10 border-[var(--color-primary)]/20 text-[var(--color-primary)]',
|
|
||||||
reposteria: 'bg-[var(--color-error)]/10 border-[var(--color-error)]/20 text-[var(--color-error)]',
|
|
||||||
especial: 'bg-[var(--color-info)]/10 border-[var(--color-info)]/20 text-[var(--color-info)]',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
|
||||||
[ProductionBatchStatus.PLANNED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
|
||||||
[ProductionBatchStatus.IN_PROGRESS]: 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
|
|
||||||
[ProductionBatchStatus.COMPLETED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
|
|
||||||
[ProductionBatchStatus.CANCELLED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
|
||||||
[ProductionBatchStatus.ON_HOLD]: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProductionSchedule: React.FC<ProductionScheduleProps> = ({
|
|
||||||
className = '',
|
|
||||||
onBatchSelected,
|
|
||||||
onScheduleUpdate,
|
|
||||||
}) => {
|
|
||||||
const [scheduleEntries, setScheduleEntries] = useState<ProductionScheduleEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
||||||
const [viewMode, setViewMode] = useState<'calendar' | 'timeline' | 'capacity'>('timeline');
|
|
||||||
const [filterStatus, setFilterStatus] = useState<ProductionBatchStatus | 'all'>('all');
|
|
||||||
const [filterProduct, setFilterProduct] = useState<string>('all');
|
|
||||||
const [draggedBatch, setDraggedBatch] = useState<ProductionScheduleEntry | null>(null);
|
|
||||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
|
||||||
const [selectedBatchForScheduling, setSelectedBatchForScheduling] = useState<ProductionBatch | null>(null);
|
|
||||||
|
|
||||||
// Mock data for demonstration
|
|
||||||
const mockCapacityResources: CapacityResource[] = [
|
|
||||||
{
|
|
||||||
id: 'horno-1',
|
|
||||||
name: 'Horno Principal',
|
|
||||||
type: 'oven',
|
|
||||||
capacity: 4,
|
|
||||||
slots: generateTimeSlots('06:00', '20:00', 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'horno-2',
|
|
||||||
name: 'Horno Secundario',
|
|
||||||
type: 'oven',
|
|
||||||
capacity: 2,
|
|
||||||
slots: generateTimeSlots('06:00', '18:00', 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'amasadora-1',
|
|
||||||
name: 'Amasadora Industrial',
|
|
||||||
type: 'mixer',
|
|
||||||
capacity: 6,
|
|
||||||
slots: generateTimeSlots('05:00', '21:00', 1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'panadero-1',
|
|
||||||
name: 'Equipo Panadería',
|
|
||||||
type: 'staff',
|
|
||||||
capacity: 8,
|
|
||||||
slots: generateTimeSlots('06:00', '14:00', 1),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function generateTimeSlots(startTime: string, endTime: string, intervalHours: number): TimeSlot[] {
|
|
||||||
const slots: TimeSlot[] = [];
|
|
||||||
const start = new Date(`2024-01-01 ${startTime}`);
|
|
||||||
const end = new Date(`2024-01-01 ${endTime}`);
|
|
||||||
|
|
||||||
while (start < end) {
|
|
||||||
const timeStr = start.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
slots.push({
|
|
||||||
time: timeStr,
|
|
||||||
capacity: 100,
|
|
||||||
utilized: Math.floor(Math.random() * 80),
|
|
||||||
available: 100 - Math.floor(Math.random() * 80),
|
|
||||||
batches: [],
|
|
||||||
});
|
|
||||||
start.setHours(start.getHours() + intervalHours);
|
|
||||||
}
|
|
||||||
|
|
||||||
return slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSchedule = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await productionService.getProductionSchedule({
|
|
||||||
start_date: selectedDate.toISOString().split('T')[0],
|
|
||||||
end_date: selectedDate.toISOString().split('T')[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
setScheduleEntries(response.data || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading schedule:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedDate]);
|
|
||||||
|
|
||||||
const filteredEntries = useMemo(() => {
|
|
||||||
return scheduleEntries.filter(entry => {
|
|
||||||
if (filterStatus !== 'all' && entry.batch?.status !== filterStatus) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (filterProduct !== 'all' && entry.batch?.recipe?.category !== filterProduct) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [scheduleEntries, filterStatus, filterProduct]);
|
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent, entry: ProductionScheduleEntry) => {
|
|
||||||
setDraggedBatch(entry);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent, newTime: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!draggedBatch) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await productionService.updateScheduleEntry(draggedBatch.id, {
|
|
||||||
scheduled_start_time: newTime,
|
|
||||||
scheduled_end_time: calculateEndTime(newTime, draggedBatch.estimated_duration_minutes),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && onScheduleUpdate) {
|
|
||||||
onScheduleUpdate(response.data);
|
|
||||||
await loadSchedule();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating schedule:', error);
|
|
||||||
} finally {
|
|
||||||
setDraggedBatch(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
|
|
||||||
const start = new Date(`2024-01-01 ${startTime}`);
|
|
||||||
start.setMinutes(start.getMinutes() + durationMinutes);
|
|
||||||
return start.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScheduleBatch = async (scheduleData: {
|
|
||||||
batch_id: string;
|
|
||||||
scheduled_date: string;
|
|
||||||
scheduled_start_time: string;
|
|
||||||
scheduled_end_time: string;
|
|
||||||
equipment_reservations?: string[];
|
|
||||||
staff_assignments?: string[];
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const response = await productionService.scheduleProductionBatch(scheduleData);
|
|
||||||
|
|
||||||
if (response.success && onScheduleUpdate) {
|
|
||||||
onScheduleUpdate(response.data);
|
|
||||||
await loadSchedule();
|
|
||||||
setIsScheduleModalOpen(false);
|
|
||||||
setSelectedBatchForScheduling(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error scheduling batch:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProductTypeColor = (category?: string) => {
|
|
||||||
return PRODUCT_TYPE_COLORS[category as keyof typeof PRODUCT_TYPE_COLORS] || 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] text-[var(--text-secondary)]';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status?: ProductionBatchStatus) => {
|
|
||||||
return STATUS_COLORS[status as keyof typeof STATUS_COLORS] || 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]';
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTimelineView = () => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex gap-4 items-center mb-6">
|
|
||||||
<DatePicker
|
|
||||||
selected={selectedDate}
|
|
||||||
onChange={(date) => setSelectedDate(date || new Date())}
|
|
||||||
dateFormat="dd/MM/yyyy"
|
|
||||||
className="w-40"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => setFilterStatus(e.target.value as ProductionBatchStatus | 'all')}
|
|
||||||
className="w-40"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los estados</option>
|
|
||||||
<option value={ProductionBatchStatus.PLANNED}>Planificado</option>
|
|
||||||
<option value={ProductionBatchStatus.IN_PROGRESS}>En progreso</option>
|
|
||||||
<option value={ProductionBatchStatus.COMPLETED}>Completado</option>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={filterProduct}
|
|
||||||
onChange={(e) => setFilterProduct(e.target.value)}
|
|
||||||
className="w-40"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los productos</option>
|
|
||||||
<option value="pan">Pan</option>
|
|
||||||
<option value="bolleria">Bollería</option>
|
|
||||||
<option value="reposteria">Repostería</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border">
|
|
||||||
<div className="grid grid-cols-25 gap-1 p-4 bg-[var(--bg-secondary)] text-xs font-medium text-[var(--text-tertiary)] border-b">
|
|
||||||
<div className="col-span-3">Producto</div>
|
|
||||||
{Array.from({ length: 22 }, (_, i) => (
|
|
||||||
<div key={i} className="text-center">
|
|
||||||
{String(i + 3).padStart(2, '0')}:00
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y">
|
|
||||||
{filteredEntries.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="grid grid-cols-25 gap-1 p-2 hover:bg-[var(--bg-secondary)]"
|
|
||||||
>
|
|
||||||
<div className="col-span-3 flex items-center space-x-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium text-sm">{entry.batch?.recipe?.name}</span>
|
|
||||||
<Badge className={`text-xs ${getStatusColor(entry.batch?.status)}`}>
|
|
||||||
{entry.batch?.status === ProductionBatchStatus.PLANNED && 'Planificado'}
|
|
||||||
{entry.batch?.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'}
|
|
||||||
{entry.batch?.status === ProductionBatchStatus.COMPLETED && 'Completado'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Array.from({ length: 22 }, (_, hourIndex) => {
|
|
||||||
const hour = hourIndex + 3;
|
|
||||||
const hourStr = `${String(hour).padStart(2, '0')}:00`;
|
|
||||||
const startHour = parseInt(entry.scheduled_start_time.split(':')[0]);
|
|
||||||
const endHour = parseInt(entry.scheduled_end_time.split(':')[0]);
|
|
||||||
|
|
||||||
const isInRange = hour >= startHour && hour < endHour;
|
|
||||||
const isStart = hour === startHour;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={hourIndex}
|
|
||||||
className={`h-12 border border-[var(--border-primary)] ${isInRange ? getProductTypeColor(entry.batch?.recipe?.category) : ''}`}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={(e) => handleDrop(e, hourStr)}
|
|
||||||
>
|
|
||||||
{isStart && (
|
|
||||||
<div
|
|
||||||
className={`h-full w-full rounded px-1 py-1 cursor-move ${getProductTypeColor(entry.batch?.recipe?.category)} border-l-4 border-blue-500`}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, entry)}
|
|
||||||
onClick={() => onBatchSelected?.(entry.batch!)}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-medium truncate">
|
|
||||||
{entry.batch?.recipe?.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">
|
|
||||||
{entry.batch?.planned_quantity} uds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderCapacityView = () => (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex gap-4 items-center mb-6">
|
|
||||||
<DatePicker
|
|
||||||
selected={selectedDate}
|
|
||||||
onChange={(date) => setSelectedDate(date || new Date())}
|
|
||||||
dateFormat="dd/MM/yyyy"
|
|
||||||
className="w-40"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{mockCapacityResources.map((resource) => (
|
|
||||||
<Card key={resource.id} className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-lg">{resource.name}</h3>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
|
||||||
{resource.type === 'oven' && 'Horno'}
|
|
||||||
{resource.type === 'mixer' && 'Amasadora'}
|
|
||||||
{resource.type === 'staff' && 'Personal'}
|
|
||||||
- Capacidad: {resource.capacity}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
|
|
||||||
{Math.round(resource.slots.reduce((acc, slot) => acc + slot.utilized, 0) / resource.slots.length)}% utilizado
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{resource.slots.slice(0, 8).map((slot, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-mono w-16">{slot.time}</span>
|
|
||||||
<div className="flex-1 h-6 bg-[var(--bg-quaternary)] rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full ${
|
|
||||||
slot.utilized > 80
|
|
||||||
? 'bg-[var(--color-error)]'
|
|
||||||
: slot.utilized > 60
|
|
||||||
? 'bg-yellow-500'
|
|
||||||
: 'bg-[var(--color-success)]'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${slot.utilized}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--text-secondary)] w-12">
|
|
||||||
{slot.utilized}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-4 w-full"
|
|
||||||
onClick={() => {
|
|
||||||
// Open detailed capacity view
|
|
||||||
console.log('Open detailed view for', resource.name);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ver detalles completos
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderCalendarView = () => (
|
|
||||||
<div className="bg-white rounded-lg border p-4">
|
|
||||||
<div className="grid grid-cols-7 gap-4 mb-4">
|
|
||||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map((day) => (
|
|
||||||
<div key={day} className="text-center font-semibold text-[var(--text-secondary)] py-2">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-4">
|
|
||||||
{Array.from({ length: 35 }, (_, i) => {
|
|
||||||
const date = new Date(selectedDate);
|
|
||||||
date.setDate(date.getDate() - date.getDay() + i);
|
|
||||||
const dayEntries = filteredEntries.filter(entry =>
|
|
||||||
new Date(entry.scheduled_date).toDateString() === date.toDateString()
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`min-h-24 p-2 border rounded-lg ${
|
|
||||||
date.toDateString() === selectedDate.toDateString()
|
|
||||||
? 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20'
|
|
||||||
: 'bg-[var(--bg-secondary)] border-[var(--border-primary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium mb-1">{date.getDate()}</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{dayEntries.slice(0, 3).map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className={`text-xs p-1 rounded cursor-pointer ${getProductTypeColor(entry.batch?.recipe?.category)}`}
|
|
||||||
onClick={() => onBatchSelected?.(entry.batch!)}
|
|
||||||
>
|
|
||||||
{entry.batch?.recipe?.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{dayEntries.length > 3 && (
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
+{dayEntries.length - 3} más
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Programación de Producción</h2>
|
|
||||||
<p className="text-[var(--text-secondary)]">Gestiona y programa la producción diaria de la panadería</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'calendar' ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('calendar')}
|
|
||||||
className="px-3"
|
|
||||||
>
|
|
||||||
Calendario
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'timeline' ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('timeline')}
|
|
||||||
className="px-3"
|
|
||||||
>
|
|
||||||
Línea de tiempo
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'capacity' ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('capacity')}
|
|
||||||
className="px-3"
|
|
||||||
>
|
|
||||||
Capacidad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => setIsScheduleModalOpen(true)}
|
|
||||||
>
|
|
||||||
Nueva programación
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{viewMode === 'calendar' && renderCalendarView()}
|
|
||||||
{viewMode === 'timeline' && renderTimelineView()}
|
|
||||||
{viewMode === 'capacity' && renderCapacityView()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Schedule Creation Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isScheduleModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsScheduleModalOpen(false);
|
|
||||||
setSelectedBatchForScheduling(null);
|
|
||||||
}}
|
|
||||||
title="Programar Lote de Producción"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Select className="w-full">
|
|
||||||
<option value="">Seleccionar lote...</option>
|
|
||||||
<option value="batch-1">Lote #001 - Pan de Molde (100 uds)</option>
|
|
||||||
<option value="batch-2">Lote #002 - Croissants (50 uds)</option>
|
|
||||||
<option value="batch-3">Lote #003 - Tarta de Chocolate (5 uds)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<DatePicker
|
|
||||||
selected={selectedDate}
|
|
||||||
onChange={(date) => setSelectedDate(date || new Date())}
|
|
||||||
dateFormat="dd/MM/yyyy"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
placeholder="Hora de inicio"
|
|
||||||
defaultValue="06:00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Duración (horas)"
|
|
||||||
defaultValue="4"
|
|
||||||
/>
|
|
||||||
<Select>
|
|
||||||
<option value="">Asignar horno...</option>
|
|
||||||
<option value="horno-1">Horno Principal</option>
|
|
||||||
<option value="horno-2">Horno Secundario</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select>
|
|
||||||
<option value="">Asignar personal...</option>
|
|
||||||
<option value="panadero-1">Equipo Panadería - Turno Mañana</option>
|
|
||||||
<option value="panadero-2">Equipo Panadería - Turno Tarde</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsScheduleModalOpen(false)}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
// Handle schedule creation
|
|
||||||
setIsScheduleModalOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Programar lote
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="font-semibold mb-3">Leyenda de productos</h3>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20 rounded"></div>
|
|
||||||
<span className="text-sm">Pan</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded"></div>
|
|
||||||
<span className="text-sm">Bollería</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-pink-100 border border-pink-300 rounded"></div>
|
|
||||||
<span className="text-sm">Repostería</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-purple-100 border border-purple-300 rounded"></div>
|
|
||||||
<span className="text-sm">Especial</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductionSchedule;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '../ProductionSchedule';
|
|
||||||
@@ -1,882 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
|
||||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
|
||||||
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../services/api/production.service';
|
|
||||||
import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types';
|
|
||||||
|
|
||||||
interface QualityControlProps {
|
|
||||||
className?: string;
|
|
||||||
batchId?: string;
|
|
||||||
checkType?: QualityCheckType;
|
|
||||||
onQualityCheckCompleted?: (result: QualityCheck) => void;
|
|
||||||
onCorrectiveActionRequired?: (check: QualityCheck, actions: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QualityCheckTemplate {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
spanishName: string;
|
|
||||||
productTypes: string[];
|
|
||||||
criteria: QualityChecklistItem[];
|
|
||||||
requiresPhotos: boolean;
|
|
||||||
passThreshold: number;
|
|
||||||
criticalPoints: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QualityChecklistItem {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
spanishDescription: string;
|
|
||||||
type: 'boolean' | 'numeric' | 'scale' | 'text';
|
|
||||||
required: boolean;
|
|
||||||
minValue?: number;
|
|
||||||
maxValue?: number;
|
|
||||||
unit?: string;
|
|
||||||
acceptableCriteria?: string;
|
|
||||||
weight: number;
|
|
||||||
isCritical: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QualityInspectionResult {
|
|
||||||
checklistId: string;
|
|
||||||
value: string | number | boolean;
|
|
||||||
notes?: string;
|
|
||||||
photo?: File;
|
|
||||||
timestamp: string;
|
|
||||||
inspector: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUALITY_CHECK_TEMPLATES: Record<string, QualityCheckTemplate> = {
|
|
||||||
visual_inspection: {
|
|
||||||
id: 'visual_inspection',
|
|
||||||
name: 'Visual Inspection',
|
|
||||||
spanishName: 'Inspección Visual',
|
|
||||||
productTypes: ['pan', 'bolleria', 'reposteria'],
|
|
||||||
requiresPhotos: true,
|
|
||||||
passThreshold: 80,
|
|
||||||
criticalPoints: ['color_defects', 'structural_defects'],
|
|
||||||
criteria: [
|
|
||||||
{
|
|
||||||
id: 'color_uniformity',
|
|
||||||
category: 'appearance',
|
|
||||||
description: 'Color uniformity',
|
|
||||||
spanishDescription: 'Uniformidad del color',
|
|
||||||
type: 'scale',
|
|
||||||
required: true,
|
|
||||||
minValue: 1,
|
|
||||||
maxValue: 5,
|
|
||||||
acceptableCriteria: 'Score 3 or higher',
|
|
||||||
weight: 20,
|
|
||||||
isCritical: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'surface_texture',
|
|
||||||
category: 'appearance',
|
|
||||||
description: 'Surface texture quality',
|
|
||||||
spanishDescription: 'Calidad de la textura superficial',
|
|
||||||
type: 'scale',
|
|
||||||
required: true,
|
|
||||||
minValue: 1,
|
|
||||||
maxValue: 5,
|
|
||||||
acceptableCriteria: 'Score 3 or higher',
|
|
||||||
weight: 15,
|
|
||||||
isCritical: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'shape_integrity',
|
|
||||||
category: 'structure',
|
|
||||||
description: 'Shape and form integrity',
|
|
||||||
spanishDescription: 'Integridad de forma y estructura',
|
|
||||||
type: 'boolean',
|
|
||||||
required: true,
|
|
||||||
acceptableCriteria: 'Must be true',
|
|
||||||
weight: 25,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'size_consistency',
|
|
||||||
category: 'dimensions',
|
|
||||||
description: 'Size consistency within batch',
|
|
||||||
spanishDescription: 'Consistencia de tamaño en el lote',
|
|
||||||
type: 'scale',
|
|
||||||
required: true,
|
|
||||||
minValue: 1,
|
|
||||||
maxValue: 5,
|
|
||||||
acceptableCriteria: 'Score 3 or higher',
|
|
||||||
weight: 20,
|
|
||||||
isCritical: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'defects_presence',
|
|
||||||
category: 'defects',
|
|
||||||
description: 'Visible defects or imperfections',
|
|
||||||
spanishDescription: 'Defectos visibles o imperfecciones',
|
|
||||||
type: 'boolean',
|
|
||||||
required: true,
|
|
||||||
acceptableCriteria: 'Must be false',
|
|
||||||
weight: 20,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
weight_check: {
|
|
||||||
id: 'weight_check',
|
|
||||||
name: 'Weight Check',
|
|
||||||
spanishName: 'Control de Peso',
|
|
||||||
productTypes: ['pan', 'bolleria', 'reposteria'],
|
|
||||||
requiresPhotos: false,
|
|
||||||
passThreshold: 95,
|
|
||||||
criticalPoints: ['weight_variance'],
|
|
||||||
criteria: [
|
|
||||||
{
|
|
||||||
id: 'individual_weight',
|
|
||||||
category: 'weight',
|
|
||||||
description: 'Individual piece weight',
|
|
||||||
spanishDescription: 'Peso individual de la pieza',
|
|
||||||
type: 'numeric',
|
|
||||||
required: true,
|
|
||||||
minValue: 0,
|
|
||||||
unit: 'g',
|
|
||||||
acceptableCriteria: 'Within ±5% of target',
|
|
||||||
weight: 40,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'batch_average_weight',
|
|
||||||
category: 'weight',
|
|
||||||
description: 'Batch average weight',
|
|
||||||
spanishDescription: 'Peso promedio del lote',
|
|
||||||
type: 'numeric',
|
|
||||||
required: true,
|
|
||||||
minValue: 0,
|
|
||||||
unit: 'g',
|
|
||||||
acceptableCriteria: 'Within ±3% of target',
|
|
||||||
weight: 30,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'weight_variance',
|
|
||||||
category: 'consistency',
|
|
||||||
description: 'Weight variance within batch',
|
|
||||||
spanishDescription: 'Variación de peso dentro del lote',
|
|
||||||
type: 'numeric',
|
|
||||||
required: true,
|
|
||||||
minValue: 0,
|
|
||||||
unit: '%',
|
|
||||||
acceptableCriteria: 'Less than 5%',
|
|
||||||
weight: 30,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
temperature_check: {
|
|
||||||
id: 'temperature_check',
|
|
||||||
name: 'Temperature Check',
|
|
||||||
spanishName: 'Control de Temperatura',
|
|
||||||
productTypes: ['pan', 'bolleria'],
|
|
||||||
requiresPhotos: false,
|
|
||||||
passThreshold: 90,
|
|
||||||
criticalPoints: ['core_temperature'],
|
|
||||||
criteria: [
|
|
||||||
{
|
|
||||||
id: 'core_temperature',
|
|
||||||
category: 'temperature',
|
|
||||||
description: 'Core temperature',
|
|
||||||
spanishDescription: 'Temperatura del núcleo',
|
|
||||||
type: 'numeric',
|
|
||||||
required: true,
|
|
||||||
minValue: 85,
|
|
||||||
maxValue: 98,
|
|
||||||
unit: '°C',
|
|
||||||
acceptableCriteria: '88-95°C',
|
|
||||||
weight: 60,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cooling_temperature',
|
|
||||||
category: 'temperature',
|
|
||||||
description: 'Cooling temperature',
|
|
||||||
spanishDescription: 'Temperatura de enfriado',
|
|
||||||
type: 'numeric',
|
|
||||||
required: false,
|
|
||||||
minValue: 18,
|
|
||||||
maxValue: 25,
|
|
||||||
unit: '°C',
|
|
||||||
acceptableCriteria: '20-23°C',
|
|
||||||
weight: 40,
|
|
||||||
isCritical: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
packaging_quality: {
|
|
||||||
id: 'packaging_quality',
|
|
||||||
name: 'Packaging Quality',
|
|
||||||
spanishName: 'Calidad del Empaquetado',
|
|
||||||
productTypes: ['pan', 'bolleria', 'reposteria'],
|
|
||||||
requiresPhotos: true,
|
|
||||||
passThreshold: 85,
|
|
||||||
criticalPoints: ['seal_integrity', 'labeling_accuracy'],
|
|
||||||
criteria: [
|
|
||||||
{
|
|
||||||
id: 'seal_integrity',
|
|
||||||
category: 'packaging',
|
|
||||||
description: 'Package seal integrity',
|
|
||||||
spanishDescription: 'Integridad del sellado del envase',
|
|
||||||
type: 'boolean',
|
|
||||||
required: true,
|
|
||||||
acceptableCriteria: 'Must be true',
|
|
||||||
weight: 30,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'labeling_accuracy',
|
|
||||||
category: 'labeling',
|
|
||||||
description: 'Label accuracy and placement',
|
|
||||||
spanishDescription: 'Precisión y colocación de etiquetas',
|
|
||||||
type: 'scale',
|
|
||||||
required: true,
|
|
||||||
minValue: 1,
|
|
||||||
maxValue: 5,
|
|
||||||
acceptableCriteria: 'Score 4 or higher',
|
|
||||||
weight: 25,
|
|
||||||
isCritical: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'package_appearance',
|
|
||||||
category: 'appearance',
|
|
||||||
description: 'Overall package appearance',
|
|
||||||
spanishDescription: 'Apariencia general del envase',
|
|
||||||
type: 'scale',
|
|
||||||
required: true,
|
|
||||||
minValue: 1,
|
|
||||||
maxValue: 5,
|
|
||||||
acceptableCriteria: 'Score 3 or higher',
|
|
||||||
weight: 20,
|
|
||||||
isCritical: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'barcode_readability',
|
|
||||||
category: 'labeling',
|
|
||||||
description: 'Barcode readability',
|
|
||||||
spanishDescription: 'Legibilidad del código de barras',
|
|
||||||
type: 'boolean',
|
|
||||||
required: true,
|
|
||||||
acceptableCriteria: 'Must be true',
|
|
||||||
weight: 25,
|
|
||||||
isCritical: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
|
||||||
[QualityCheckStatus.SCHEDULED]: 'bg-blue-100 text-blue-800',
|
|
||||||
[QualityCheckStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800',
|
|
||||||
[QualityCheckStatus.PASSED]: 'bg-green-100 text-green-800',
|
|
||||||
[QualityCheckStatus.FAILED]: 'bg-red-100 text-red-800',
|
|
||||||
[QualityCheckStatus.REQUIRES_REVIEW]: 'bg-orange-100 text-orange-800',
|
|
||||||
[QualityCheckStatus.CANCELLED]: 'bg-gray-100 text-gray-800',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const QualityControl: React.FC<QualityControlProps> = ({
|
|
||||||
className = '',
|
|
||||||
batchId,
|
|
||||||
checkType,
|
|
||||||
onQualityCheckCompleted,
|
|
||||||
onCorrectiveActionRequired,
|
|
||||||
}) => {
|
|
||||||
const [qualityChecks, setQualityChecks] = useState<QualityCheckResponse[]>([]);
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
|
|
||||||
const [activeCheck, setActiveCheck] = useState<QualityCheck | null>(null);
|
|
||||||
const [inspectionResults, setInspectionResults] = useState<Record<string, QualityInspectionResult>>({});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
|
||||||
const [uploadedPhotos, setUploadedPhotos] = useState<Record<string, File>>({});
|
|
||||||
const [currentInspector, setCurrentInspector] = useState<string>('inspector-1');
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const loadQualityChecks = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params: any = {};
|
|
||||||
if (batchId) params.batch_id = batchId;
|
|
||||||
if (checkType) params.check_type = checkType;
|
|
||||||
|
|
||||||
const response = await productionService.getQualityChecks(params);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setQualityChecks(response.data.items || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading quality checks:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [batchId, checkType]);
|
|
||||||
|
|
||||||
const startQualityCheck = (template: QualityCheckTemplate, batch?: any) => {
|
|
||||||
setSelectedTemplate(template);
|
|
||||||
setInspectionResults({});
|
|
||||||
setUploadedPhotos({});
|
|
||||||
setIsInspectionModalOpen(true);
|
|
||||||
|
|
||||||
// Initialize empty results for all criteria
|
|
||||||
template.criteria.forEach(criterion => {
|
|
||||||
setInspectionResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[criterion.id]: {
|
|
||||||
checklistId: criterion.id,
|
|
||||||
value: criterion.type === 'boolean' ? false : criterion.type === 'numeric' ? 0 : '',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
inspector: currentInspector,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateInspectionResult = (criterionId: string, value: string | number | boolean, notes?: string) => {
|
|
||||||
setInspectionResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[criterionId]: {
|
|
||||||
...prev[criterionId],
|
|
||||||
value,
|
|
||||||
notes,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoUpload = (criterionId: string, file: File) => {
|
|
||||||
setUploadedPhotos(prev => ({
|
|
||||||
...prev,
|
|
||||||
[criterionId]: file,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update inspection result with photo reference
|
|
||||||
setInspectionResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[criterionId]: {
|
|
||||||
...prev[criterionId],
|
|
||||||
photo: file,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateOverallScore = (): number => {
|
|
||||||
if (!selectedTemplate) return 0;
|
|
||||||
|
|
||||||
let totalScore = 0;
|
|
||||||
let totalWeight = 0;
|
|
||||||
|
|
||||||
selectedTemplate.criteria.forEach(criterion => {
|
|
||||||
const result = inspectionResults[criterion.id];
|
|
||||||
if (result) {
|
|
||||||
let score = 0;
|
|
||||||
|
|
||||||
if (criterion.type === 'boolean') {
|
|
||||||
score = result.value ? 100 : 0;
|
|
||||||
} else if (criterion.type === 'scale') {
|
|
||||||
const numValue = Number(result.value);
|
|
||||||
score = (numValue / (criterion.maxValue || 5)) * 100;
|
|
||||||
} else if (criterion.type === 'numeric') {
|
|
||||||
// For numeric values, assume pass/fail based on acceptable range
|
|
||||||
score = 100; // Simplified - would need more complex logic
|
|
||||||
}
|
|
||||||
|
|
||||||
totalScore += score * criterion.weight;
|
|
||||||
totalWeight += criterion.weight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return totalWeight > 0 ? totalScore / totalWeight : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkCriticalFailures = (): string[] => {
|
|
||||||
if (!selectedTemplate) return [];
|
|
||||||
|
|
||||||
const failures: string[] = [];
|
|
||||||
|
|
||||||
selectedTemplate.criteria.forEach(criterion => {
|
|
||||||
if (criterion.isCritical) {
|
|
||||||
const result = inspectionResults[criterion.id];
|
|
||||||
if (result) {
|
|
||||||
if (criterion.type === 'boolean' && !result.value) {
|
|
||||||
failures.push(criterion.spanishDescription);
|
|
||||||
} else if (criterion.type === 'scale' && Number(result.value) < 3) {
|
|
||||||
failures.push(criterion.spanishDescription);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return failures;
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeQualityCheck = async () => {
|
|
||||||
if (!selectedTemplate) return;
|
|
||||||
|
|
||||||
const overallScore = calculateOverallScore();
|
|
||||||
const criticalFailures = checkCriticalFailures();
|
|
||||||
const passed = overallScore >= selectedTemplate.passThreshold && criticalFailures.length === 0;
|
|
||||||
|
|
||||||
const checkData = {
|
|
||||||
status: passed ? QualityCheckStatus.PASSED : QualityCheckStatus.FAILED,
|
|
||||||
results: {
|
|
||||||
overallScore,
|
|
||||||
criticalFailures,
|
|
||||||
individualResults: inspectionResults,
|
|
||||||
photos: Object.keys(uploadedPhotos),
|
|
||||||
},
|
|
||||||
notes: `Inspección completada. Puntuación: ${overallScore.toFixed(1)}%`,
|
|
||||||
corrective_actions: criticalFailures.length > 0 ? [
|
|
||||||
`Fallas críticas encontradas: ${criticalFailures.join(', ')}`,
|
|
||||||
'Revisar proceso de producción',
|
|
||||||
'Re-entrenar personal si es necesario'
|
|
||||||
] : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// This would be the actual check ID from the created quality check
|
|
||||||
const mockCheckId = 'check-id';
|
|
||||||
const response = await productionService.completeQualityCheck(mockCheckId, checkData);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
if (onQualityCheckCompleted) {
|
|
||||||
onQualityCheckCompleted(response.data as unknown as QualityCheck);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!passed && checkData.corrective_actions && onCorrectiveActionRequired) {
|
|
||||||
onCorrectiveActionRequired(response.data as unknown as QualityCheck, checkData.corrective_actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsInspectionModalOpen(false);
|
|
||||||
setSelectedTemplate(null);
|
|
||||||
await loadQualityChecks();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error completing quality check:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderInspectionForm = () => {
|
|
||||||
if (!selectedTemplate) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-2">{selectedTemplate.spanishName}</h3>
|
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
Umbral de aprobación: {selectedTemplate.passThreshold}%
|
|
||||||
</p>
|
|
||||||
{selectedTemplate.criticalPoints.length > 0 && (
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Puntos críticos: {selectedTemplate.criticalPoints.length}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{selectedTemplate.criteria.map((criterion) => (
|
|
||||||
<Card key={criterion.id} className={`p-4 ${criterion.isCritical ? 'border-orange-200 bg-orange-50' : ''}`}>
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-medium text-gray-900">{criterion.spanishDescription}</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{criterion.acceptableCriteria}</p>
|
|
||||||
{criterion.isCritical && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 text-xs mt-1">
|
|
||||||
Punto Crítico
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500">Peso: {criterion.weight}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{criterion.type === 'boolean' && (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={criterion.id}
|
|
||||||
value="true"
|
|
||||||
checked={inspectionResults[criterion.id]?.value === true}
|
|
||||||
onChange={() => updateInspectionResult(criterion.id, true)}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
Sí / Pasa
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={criterion.id}
|
|
||||||
value="false"
|
|
||||||
checked={inspectionResults[criterion.id]?.value === false}
|
|
||||||
onChange={() => updateInspectionResult(criterion.id, false)}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
No / Falla
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{criterion.type === 'scale' && (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm">1</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={criterion.minValue || 1}
|
|
||||||
max={criterion.maxValue || 5}
|
|
||||||
step="1"
|
|
||||||
value={inspectionResults[criterion.id]?.value || criterion.minValue || 1}
|
|
||||||
onChange={(e) => updateInspectionResult(criterion.id, parseInt(e.target.value))}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{criterion.maxValue || 5}</span>
|
|
||||||
<span className="min-w-8 text-center font-medium">
|
|
||||||
{inspectionResults[criterion.id]?.value || criterion.minValue || 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{criterion.type === 'numeric' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={criterion.minValue}
|
|
||||||
max={criterion.maxValue}
|
|
||||||
step="0.1"
|
|
||||||
value={inspectionResults[criterion.id]?.value || ''}
|
|
||||||
onChange={(e) => updateInspectionResult(criterion.id, parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
{criterion.unit && <span className="text-sm text-gray-600">{criterion.unit}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Notas adicionales (opcional)"
|
|
||||||
value={inspectionResults[criterion.id]?.notes || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const currentResult = inspectionResults[criterion.id];
|
|
||||||
if (currentResult) {
|
|
||||||
updateInspectionResult(criterion.id, currentResult.value, e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTemplate.requiresPhotos && (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
handlePhotoUpload(criterion.id, file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
📸 {uploadedPhotos[criterion.id] ? 'Cambiar foto' : 'Tomar foto'}
|
|
||||||
</Button>
|
|
||||||
{uploadedPhotos[criterion.id] && (
|
|
||||||
<p className="text-sm text-green-600 mt-1">
|
|
||||||
✓ Foto capturada: {uploadedPhotos[criterion.id].name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-4 bg-gray-50">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="font-medium">Puntuación general</span>
|
|
||||||
<span className="text-2xl font-bold text-blue-600">
|
|
||||||
{calculateOverallScore().toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-2">
|
|
||||||
<div
|
|
||||||
className={`h-3 rounded-full transition-all duration-500 ${
|
|
||||||
calculateOverallScore() >= selectedTemplate.passThreshold
|
|
||||||
? 'bg-green-500'
|
|
||||||
: 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${calculateOverallScore()}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
|
||||||
<span>Umbral: {selectedTemplate.passThreshold}%</span>
|
|
||||||
<span className={
|
|
||||||
calculateOverallScore() >= selectedTemplate.passThreshold
|
|
||||||
? 'text-green-600 font-medium'
|
|
||||||
: 'text-red-600 font-medium'
|
|
||||||
}>
|
|
||||||
{calculateOverallScore() >= selectedTemplate.passThreshold ? '✓ APROBADO' : '✗ REPROBADO'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{checkCriticalFailures().length > 0 && (
|
|
||||||
<div className="mt-3 p-2 bg-red-100 border border-red-200 rounded">
|
|
||||||
<p className="text-sm font-medium text-red-800">Fallas críticas detectadas:</p>
|
|
||||||
<ul className="text-sm text-red-700 mt-1">
|
|
||||||
{checkCriticalFailures().map((failure, index) => (
|
|
||||||
<li key={index}>• {failure}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderQualityChecksTable = () => {
|
|
||||||
const columns = [
|
|
||||||
{ key: 'batch', label: 'Lote', sortable: true },
|
|
||||||
{ key: 'type', label: 'Tipo de control', sortable: true },
|
|
||||||
{ key: 'status', label: 'Estado', sortable: true },
|
|
||||||
{ key: 'inspector', label: 'Inspector', sortable: false },
|
|
||||||
{ key: 'scheduled_date', label: 'Fecha programada', sortable: true },
|
|
||||||
{ key: 'completed_date', label: 'Fecha completada', sortable: true },
|
|
||||||
{ key: 'actions', label: 'Acciones', sortable: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = qualityChecks.map(check => ({
|
|
||||||
id: check.id,
|
|
||||||
batch: `#${check.batch?.batch_number || 'N/A'}`,
|
|
||||||
type: QUALITY_CHECK_TEMPLATES[check.check_type]?.spanishName || check.check_type,
|
|
||||||
status: (
|
|
||||||
<Badge className={STATUS_COLORS[check.status]}>
|
|
||||||
{check.status === QualityCheckStatus.SCHEDULED && 'Programado'}
|
|
||||||
{check.status === QualityCheckStatus.IN_PROGRESS && 'En progreso'}
|
|
||||||
{check.status === QualityCheckStatus.PASSED && 'Aprobado'}
|
|
||||||
{check.status === QualityCheckStatus.FAILED && 'Reprobado'}
|
|
||||||
{check.status === QualityCheckStatus.REQUIRES_REVIEW && 'Requiere revisión'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
inspector: check.inspector || 'No asignado',
|
|
||||||
scheduled_date: check.scheduled_date
|
|
||||||
? new Date(check.scheduled_date).toLocaleDateString('es-ES')
|
|
||||||
: '-',
|
|
||||||
completed_date: check.completed_date
|
|
||||||
? new Date(check.completed_date).toLocaleDateString('es-ES')
|
|
||||||
: '-',
|
|
||||||
actions: (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{check.status === QualityCheckStatus.SCHEDULED && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const template = QUALITY_CHECK_TEMPLATES[check.check_type];
|
|
||||||
if (template) {
|
|
||||||
startQualityCheck(template);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Iniciar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Ver detalles
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <Table columns={columns} data={data} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Control de Calidad</h2>
|
|
||||||
<p className="text-gray-600">Gestiona las inspecciones de calidad y cumplimiento</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
value={currentInspector}
|
|
||||||
onChange={(e) => setCurrentInspector(e.target.value)}
|
|
||||||
className="w-48"
|
|
||||||
>
|
|
||||||
<option value="inspector-1">María García (Inspector)</option>
|
|
||||||
<option value="inspector-2">Juan López (Supervisor)</option>
|
|
||||||
<option value="inspector-3">Ana Martín (Jefe Calidad)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
// Show modal to select quality check type
|
|
||||||
const template = QUALITY_CHECK_TEMPLATES.visual_inspection;
|
|
||||||
startQualityCheck(template);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nueva inspección
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Start Templates */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{Object.values(QUALITY_CHECK_TEMPLATES).map((template) => (
|
|
||||||
<Card
|
|
||||||
key={template.id}
|
|
||||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow border-2 border-transparent hover:border-blue-200"
|
|
||||||
onClick={() => startQualityCheck(template)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="font-semibold text-gray-900">{template.spanishName}</h3>
|
|
||||||
{template.requiresPhotos && (
|
|
||||||
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
|
||||||
📸 Fotos
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
{template.criteria.length} criterios • Umbral: {template.passThreshold}%
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{template.productTypes.map((type) => (
|
|
||||||
<Badge key={type} className="text-xs bg-gray-100 text-gray-700">
|
|
||||||
{type === 'pan' && 'Pan'}
|
|
||||||
{type === 'bolleria' && 'Bollería'}
|
|
||||||
{type === 'reposteria' && 'Repostería'}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{template.criticalPoints.length > 0 && (
|
|
||||||
<p className="text-xs text-orange-600 mt-2">
|
|
||||||
{template.criticalPoints.length} puntos críticos
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quality Checks Table */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">Controles de calidad recientes</h3>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select className="w-40">
|
|
||||||
<option value="all">Todos los estados</option>
|
|
||||||
<option value={QualityCheckStatus.SCHEDULED}>Programados</option>
|
|
||||||
<option value={QualityCheckStatus.IN_PROGRESS}>En progreso</option>
|
|
||||||
<option value={QualityCheckStatus.PASSED}>Aprobados</option>
|
|
||||||
<option value={QualityCheckStatus.FAILED}>Reprobados</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={loadQualityChecks}>
|
|
||||||
Actualizar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
renderQualityChecksTable()
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Quality Check Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isInspectionModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsInspectionModalOpen(false);
|
|
||||||
setSelectedTemplate(null);
|
|
||||||
}}
|
|
||||||
title={`Control de Calidad: ${selectedTemplate?.spanishName}`}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{renderInspectionForm()}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-6 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsInspectionModalOpen(false);
|
|
||||||
setSelectedTemplate(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={completeQualityCheck}
|
|
||||||
disabled={!selectedTemplate || Object.keys(inspectionResults).length === 0}
|
|
||||||
>
|
|
||||||
Completar inspección
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Tasa de aprobación</p>
|
|
||||||
<p className="text-2xl font-bold text-green-600">94.2%</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">✅</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Controles pendientes</p>
|
|
||||||
<p className="text-2xl font-bold text-orange-600">7</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">⏳</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Fallas críticas</p>
|
|
||||||
<p className="text-2xl font-bold text-red-600">2</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">🚨</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Controles hoy</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-600">23</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl">📋</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QualityControl;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '../QualityControl';
|
|
||||||
@@ -1,794 +0,0 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import { Card, Button, Badge, Input, Modal, Table, Select } from '../../ui';
|
|
||||||
import type { Recipe, RecipeIngredient, RecipeInstruction, NutritionalInfo, DifficultyLevel } from '../../../types/production.types';
|
|
||||||
|
|
||||||
interface RecipeDisplayProps {
|
|
||||||
className?: string;
|
|
||||||
recipe: Recipe;
|
|
||||||
editable?: boolean;
|
|
||||||
showNutrition?: boolean;
|
|
||||||
showCosting?: boolean;
|
|
||||||
onScaleChange?: (scaleFactor: number, scaledRecipe: Recipe) => void;
|
|
||||||
onVersionUpdate?: (newVersion: Recipe) => void;
|
|
||||||
onCostCalculation?: (totalCost: number, costPerUnit: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScaledIngredient extends RecipeIngredient {
|
|
||||||
scaledQuantity: number;
|
|
||||||
scaledCost?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScaledInstruction extends RecipeInstruction {
|
|
||||||
scaledDuration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScaledRecipe extends Omit<Recipe, 'ingredients' | 'instructions'> {
|
|
||||||
scaledYieldQuantity: number;
|
|
||||||
scaledTotalTime: number;
|
|
||||||
ingredients: ScaledIngredient[];
|
|
||||||
instructions: ScaledInstruction[];
|
|
||||||
scaleFactor: number;
|
|
||||||
estimatedTotalCost?: number;
|
|
||||||
costPerScaledUnit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimerState {
|
|
||||||
instructionId: string;
|
|
||||||
duration: number;
|
|
||||||
remaining: number;
|
|
||||||
isActive: boolean;
|
|
||||||
isComplete: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIFFICULTY_COLORS = {
|
|
||||||
[DifficultyLevel.BEGINNER]: 'bg-green-100 text-green-800',
|
|
||||||
[DifficultyLevel.INTERMEDIATE]: 'bg-yellow-100 text-yellow-800',
|
|
||||||
[DifficultyLevel.ADVANCED]: 'bg-orange-100 text-orange-800',
|
|
||||||
[DifficultyLevel.EXPERT]: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DIFFICULTY_LABELS = {
|
|
||||||
[DifficultyLevel.BEGINNER]: 'Principiante',
|
|
||||||
[DifficultyLevel.INTERMEDIATE]: 'Intermedio',
|
|
||||||
[DifficultyLevel.ADVANCED]: 'Avanzado',
|
|
||||||
[DifficultyLevel.EXPERT]: 'Experto',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALLERGEN_ICONS: Record<string, string> = {
|
|
||||||
gluten: '🌾',
|
|
||||||
milk: '🥛',
|
|
||||||
eggs: '🥚',
|
|
||||||
nuts: '🥜',
|
|
||||||
soy: '🫘',
|
|
||||||
sesame: '🌰',
|
|
||||||
fish: '🐟',
|
|
||||||
shellfish: '🦐',
|
|
||||||
};
|
|
||||||
|
|
||||||
const EQUIPMENT_ICONS: Record<string, string> = {
|
|
||||||
oven: '🔥',
|
|
||||||
mixer: '🥄',
|
|
||||||
scale: '⚖️',
|
|
||||||
bowl: '🥣',
|
|
||||||
whisk: '🔄',
|
|
||||||
spatula: '🍴',
|
|
||||||
thermometer: '🌡️',
|
|
||||||
timer: '⏰',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecipeDisplay: React.FC<RecipeDisplayProps> = ({
|
|
||||||
className = '',
|
|
||||||
recipe,
|
|
||||||
editable = false,
|
|
||||||
showNutrition = true,
|
|
||||||
showCosting = false,
|
|
||||||
onScaleChange,
|
|
||||||
onVersionUpdate,
|
|
||||||
onCostCalculation,
|
|
||||||
}) => {
|
|
||||||
const [scaleFactor, setScaleFactor] = useState<number>(1);
|
|
||||||
const [activeTimers, setActiveTimers] = useState<Record<string, TimerState>>({});
|
|
||||||
const [isNutritionModalOpen, setIsNutritionModalOpen] = useState(false);
|
|
||||||
const [isCostingModalOpen, setIsCostingModalOpen] = useState(false);
|
|
||||||
const [isEquipmentModalOpen, setIsEquipmentModalOpen] = useState(false);
|
|
||||||
const [selectedInstruction, setSelectedInstruction] = useState<RecipeInstruction | null>(null);
|
|
||||||
const [showAllergensDetail, setShowAllergensDetail] = useState(false);
|
|
||||||
|
|
||||||
// Mock ingredient costs for demonstration
|
|
||||||
const ingredientCosts: Record<string, number> = {
|
|
||||||
flour: 1.2, // €/kg
|
|
||||||
water: 0.001, // €/l
|
|
||||||
salt: 0.8, // €/kg
|
|
||||||
yeast: 8.5, // €/kg
|
|
||||||
sugar: 1.5, // €/kg
|
|
||||||
butter: 6.2, // €/kg
|
|
||||||
milk: 1.3, // €/l
|
|
||||||
eggs: 0.25, // €/unit
|
|
||||||
};
|
|
||||||
|
|
||||||
const scaledRecipe = useMemo((): ScaledRecipe => {
|
|
||||||
const scaledIngredients: ScaledIngredient[] = recipe.ingredients.map(ingredient => ({
|
|
||||||
...ingredient,
|
|
||||||
scaledQuantity: ingredient.quantity * scaleFactor,
|
|
||||||
scaledCost: ingredientCosts[ingredient.ingredient_id]
|
|
||||||
? ingredientCosts[ingredient.ingredient_id] * ingredient.quantity * scaleFactor
|
|
||||||
: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const scaledInstructions: ScaledInstruction[] = recipe.instructions.map(instruction => ({
|
|
||||||
...instruction,
|
|
||||||
scaledDuration: instruction.duration_minutes ? Math.ceil(instruction.duration_minutes * scaleFactor) : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const estimatedTotalCost = scaledIngredients.reduce((total, ingredient) =>
|
|
||||||
total + (ingredient.scaledCost || 0), 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const scaledYieldQuantity = recipe.yield_quantity * scaleFactor;
|
|
||||||
const costPerScaledUnit = scaledYieldQuantity > 0 ? estimatedTotalCost / scaledYieldQuantity : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...recipe,
|
|
||||||
scaledYieldQuantity,
|
|
||||||
scaledTotalTime: Math.ceil(recipe.total_time_minutes * scaleFactor),
|
|
||||||
ingredients: scaledIngredients,
|
|
||||||
instructions: scaledInstructions,
|
|
||||||
scaleFactor,
|
|
||||||
estimatedTotalCost,
|
|
||||||
costPerScaledUnit,
|
|
||||||
};
|
|
||||||
}, [recipe, scaleFactor, ingredientCosts]);
|
|
||||||
|
|
||||||
const handleScaleChange = useCallback((newScaleFactor: number) => {
|
|
||||||
setScaleFactor(newScaleFactor);
|
|
||||||
if (onScaleChange) {
|
|
||||||
onScaleChange(newScaleFactor, scaledRecipe as unknown as Recipe);
|
|
||||||
}
|
|
||||||
}, [scaledRecipe, onScaleChange]);
|
|
||||||
|
|
||||||
const startTimer = (instruction: RecipeInstruction) => {
|
|
||||||
if (!instruction.duration_minutes) return;
|
|
||||||
|
|
||||||
const timerState: TimerState = {
|
|
||||||
instructionId: instruction.step_number.toString(),
|
|
||||||
duration: instruction.duration_minutes * 60, // Convert to seconds
|
|
||||||
remaining: instruction.duration_minutes * 60,
|
|
||||||
isActive: true,
|
|
||||||
isComplete: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
setActiveTimers(prev => ({
|
|
||||||
...prev,
|
|
||||||
[instruction.step_number.toString()]: timerState,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setActiveTimers(prev => {
|
|
||||||
const current = prev[instruction.step_number.toString()];
|
|
||||||
if (!current || current.remaining <= 0) {
|
|
||||||
clearInterval(interval);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[instruction.step_number.toString()]: {
|
|
||||||
...current,
|
|
||||||
remaining: 0,
|
|
||||||
isActive: false,
|
|
||||||
isComplete: true,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[instruction.step_number.toString()]: {
|
|
||||||
...current,
|
|
||||||
remaining: current.remaining - 1,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopTimer = (instructionId: string) => {
|
|
||||||
setActiveTimers(prev => ({
|
|
||||||
...prev,
|
|
||||||
[instructionId]: {
|
|
||||||
...prev[instructionId],
|
|
||||||
isActive: false,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderScalingControls = () => (
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="font-semibold text-lg mb-4">Escalado de receta</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Factor de escala
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0.1"
|
|
||||||
max="10"
|
|
||||||
step="0.1"
|
|
||||||
value={scaleFactor}
|
|
||||||
onChange={(e) => handleScaleChange(parseFloat(e.target.value) || 1)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Cantidad original
|
|
||||||
</label>
|
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
|
||||||
{recipe.yield_quantity} {recipe.yield_unit}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Cantidad escalada
|
|
||||||
</label>
|
|
||||||
<p className="text-lg font-semibold text-blue-600">
|
|
||||||
{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleScaleChange(0.5)}
|
|
||||||
className={scaleFactor === 0.5 ? 'bg-blue-50 border-blue-300' : ''}
|
|
||||||
>
|
|
||||||
1/2
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleScaleChange(1)}
|
|
||||||
className={scaleFactor === 1 ? 'bg-blue-50 border-blue-300' : ''}
|
|
||||||
>
|
|
||||||
1x
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleScaleChange(2)}
|
|
||||||
className={scaleFactor === 2 ? 'bg-blue-50 border-blue-300' : ''}
|
|
||||||
>
|
|
||||||
2x
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleScaleChange(3)}
|
|
||||||
className={scaleFactor === 3 ? 'bg-blue-50 border-blue-300' : ''}
|
|
||||||
>
|
|
||||||
3x
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleScaleChange(5)}
|
|
||||||
className={scaleFactor === 5 ? 'bg-blue-50 border-blue-300' : ''}
|
|
||||||
>
|
|
||||||
5x
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scaleFactor !== 1 && (
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
<span className="font-medium">Tiempo total escalado:</span> {Math.ceil(scaledRecipe.scaledTotalTime / 60)}h {scaledRecipe.scaledTotalTime % 60}m
|
|
||||||
</p>
|
|
||||||
{showCosting && scaledRecipe.estimatedTotalCost && (
|
|
||||||
<p className="text-sm text-blue-800 mt-1">
|
|
||||||
<span className="font-medium">Costo estimado:</span> €{scaledRecipe.estimatedTotalCost.toFixed(2)}
|
|
||||||
(€{scaledRecipe.costPerScaledUnit?.toFixed(3)}/{recipe.yield_unit})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRecipeHeader = () => (
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{recipe.name}</h1>
|
|
||||||
<p className="text-gray-600 mt-1">{recipe.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className={DIFFICULTY_COLORS[recipe.difficulty_level]}>
|
|
||||||
{DIFFICULTY_LABELS[recipe.difficulty_level]}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
v{recipe.version}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Preparación</p>
|
|
||||||
<p className="font-semibold">{recipe.prep_time_minutes} min</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Cocción</p>
|
|
||||||
<p className="font-semibold">{recipe.cook_time_minutes} min</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Total</p>
|
|
||||||
<p className="font-semibold">{Math.ceil(recipe.total_time_minutes / 60)}h {recipe.total_time_minutes % 60}m</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Rendimiento</p>
|
|
||||||
<p className="font-semibold">{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{recipe.allergen_warnings.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-medium text-gray-700">Alérgenos:</p>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAllergensDetail(!showAllergensDetail)}
|
|
||||||
>
|
|
||||||
{showAllergensDetail ? 'Ocultar' : 'Ver detalles'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{recipe.allergen_warnings.map((allergen) => (
|
|
||||||
<Badge
|
|
||||||
key={allergen}
|
|
||||||
className="bg-red-100 text-red-800 border border-red-200"
|
|
||||||
>
|
|
||||||
{ALLERGEN_ICONS[allergen]} {allergen}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAllergensDetail && (
|
|
||||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p className="text-sm text-red-800">
|
|
||||||
⚠️ Este producto contiene los siguientes alérgenos. Revisar cuidadosamente
|
|
||||||
antes del consumo si existe alguna alergia o intolerancia alimentaria.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recipe.storage_instructions && (
|
|
||||||
<div className="mt-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
<span className="font-medium">Conservación:</span> {recipe.storage_instructions}
|
|
||||||
{recipe.shelf_life_hours && (
|
|
||||||
<span className="ml-2">
|
|
||||||
• Vida útil: {Math.ceil(recipe.shelf_life_hours / 24)} días
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 lg:w-48">
|
|
||||||
{showNutrition && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsNutritionModalOpen(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
📊 Información nutricional
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCosting && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsCostingModalOpen(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
💰 Análisis de costos
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsEquipmentModalOpen(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
🔧 Equipo necesario
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{editable && onVersionUpdate && (
|
|
||||||
<Button variant="primary" className="w-full">
|
|
||||||
✏️ Editar receta
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderIngredients = () => (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Ingredientes</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{scaledRecipe.ingredients.map((ingredient, index) => (
|
|
||||||
<div
|
|
||||||
key={`${ingredient.ingredient_id}-${index}`}
|
|
||||||
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-gray-900">{ingredient.ingredient_name}</p>
|
|
||||||
{ingredient.preparation_notes && (
|
|
||||||
<p className="text-sm text-gray-600 italic">{ingredient.preparation_notes}</p>
|
|
||||||
)}
|
|
||||||
{ingredient.is_optional && (
|
|
||||||
<Badge className="bg-gray-100 text-gray-600 text-xs mt-1">
|
|
||||||
Opcional
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right ml-4">
|
|
||||||
<p className="font-semibold text-lg">
|
|
||||||
{ingredient.scaledQuantity.toFixed(ingredient.scaledQuantity < 1 ? 2 : 0)} {ingredient.unit}
|
|
||||||
</p>
|
|
||||||
{scaleFactor !== 1 && (
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
(original: {ingredient.quantity} {ingredient.unit})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{showCosting && ingredient.scaledCost && (
|
|
||||||
<p className="text-sm text-green-600">
|
|
||||||
€{ingredient.scaledCost.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCosting && scaledRecipe.estimatedTotalCost && (
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-semibold">Costo total estimado:</span>
|
|
||||||
<span className="text-xl font-bold text-green-600">
|
|
||||||
€{scaledRecipe.estimatedTotalCost.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 text-right">
|
|
||||||
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderInstructions = () => (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Instrucciones</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{scaledRecipe.instructions.map((instruction, index) => {
|
|
||||||
const timer = activeTimers[instruction.step_number.toString()];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={instruction.step_number}
|
|
||||||
className={`p-4 border rounded-lg ${
|
|
||||||
instruction.critical_control_point
|
|
||||||
? 'border-orange-300 bg-orange-50'
|
|
||||||
: 'border-gray-200 bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
|
||||||
{instruction.step_number}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{instruction.duration_minutes && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
⏱️ {instruction.scaledDuration || instruction.duration_minutes} min
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{instruction.temperature && (
|
|
||||||
<Badge className="bg-red-100 text-red-800">
|
|
||||||
🌡️ {instruction.temperature}°C
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{instruction.critical_control_point && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800">
|
|
||||||
🚨 PCC
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{instruction.duration_minutes && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{!timer?.isActive && !timer?.isComplete && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startTimer(instruction)}
|
|
||||||
>
|
|
||||||
▶️ Iniciar timer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{timer?.isActive && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg font-mono font-bold text-blue-600">
|
|
||||||
{formatTime(timer.remaining)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => stopTimer(instruction.step_number.toString())}
|
|
||||||
>
|
|
||||||
⏸️
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{timer?.isComplete && (
|
|
||||||
<Badge className="bg-green-100 text-green-800 animate-pulse">
|
|
||||||
✅ Completado
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-800 leading-relaxed mb-2">
|
|
||||||
{instruction.instruction}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{instruction.equipment && instruction.equipment.length > 0 && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<p className="text-sm text-gray-600 mb-1">Equipo necesario:</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{instruction.equipment.map((equipment, idx) => (
|
|
||||||
<Badge key={idx} className="bg-gray-100 text-gray-700 text-xs">
|
|
||||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{instruction.tips && (
|
|
||||||
<div className="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
💡 <span className="font-medium">Tip:</span> {instruction.tips}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderNutritionModal = () => (
|
|
||||||
<Modal
|
|
||||||
isOpen={isNutritionModalOpen}
|
|
||||||
onClose={() => setIsNutritionModalOpen(false)}
|
|
||||||
title="Información Nutricional"
|
|
||||||
>
|
|
||||||
{recipe.nutritional_info ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-600">Calorías por porción</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
{recipe.nutritional_info.calories_per_serving || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-600">Tamaño de porción</p>
|
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
|
||||||
{recipe.nutritional_info.serving_size || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Proteínas</p>
|
|
||||||
<p className="text-lg font-semibold">{recipe.nutritional_info.protein_g || 0}g</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Carbohidratos</p>
|
|
||||||
<p className="text-lg font-semibold">{recipe.nutritional_info.carbohydrates_g || 0}g</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Grasas</p>
|
|
||||||
<p className="text-lg font-semibold">{recipe.nutritional_info.fat_g || 0}g</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Fibra</p>
|
|
||||||
<p className="text-lg font-semibold">{recipe.nutritional_info.fiber_g || 0}g</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Azúcares</p>
|
|
||||||
<p className="text-lg font-semibold">{recipe.nutritional_info.sugar_g || 0}g</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Sodio</p>
|
|
||||||
<p className="text-lg font-semibold">{recipe.nutritional_info.sodium_mg || 0}mg</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
<span className="font-medium">Porciones por lote escalado:</span> {
|
|
||||||
recipe.nutritional_info.servings_per_batch
|
|
||||||
? Math.ceil(recipe.nutritional_info.servings_per_batch * scaleFactor)
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-500 text-center py-8">
|
|
||||||
No hay información nutricional disponible para esta receta.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderCostingModal = () => (
|
|
||||||
<Modal
|
|
||||||
isOpen={isCostingModalOpen}
|
|
||||||
onClose={() => setIsCostingModalOpen(false)}
|
|
||||||
title="Análisis de Costos"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-green-50 border border-green-200 p-4 rounded-lg">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="font-semibold text-green-900">Costo total</span>
|
|
||||||
<span className="text-2xl font-bold text-green-600">
|
|
||||||
€{scaledRecipe.estimatedTotalCost?.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-green-700">
|
|
||||||
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold mb-3">Desglose por ingrediente</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{scaledRecipe.ingredients.map((ingredient, index) => (
|
|
||||||
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{ingredient.ingredient_name}</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{ingredient.scaledQuantity.toFixed(2)} {ingredient.unit}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold text-green-600">
|
|
||||||
€{ingredient.scaledCost?.toFixed(2) || '0.00'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-blue-900 mb-2">Análisis de rentabilidad</h4>
|
|
||||||
<div className="space-y-1 text-sm text-blue-800">
|
|
||||||
<p>• Costo de ingredientes: €{scaledRecipe.estimatedTotalCost?.toFixed(2)}</p>
|
|
||||||
<p>• Margen sugerido (300%): €{((scaledRecipe.estimatedTotalCost || 0) * 3).toFixed(2)}</p>
|
|
||||||
<p>• Precio de venta sugerido por {recipe.yield_unit}: €{((scaledRecipe.costPerScaledUnit || 0) * 4).toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderEquipmentModal = () => (
|
|
||||||
<Modal
|
|
||||||
isOpen={isEquipmentModalOpen}
|
|
||||||
onClose={() => setIsEquipmentModalOpen(false)}
|
|
||||||
title="Equipo Necesario"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold mb-3">Equipo general de la receta</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{recipe.equipment_needed.map((equipment, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
|
||||||
<span className="text-xl">
|
|
||||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm">{equipment}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold mb-3">Equipo por instrucción</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{recipe.instructions
|
|
||||||
.filter(instruction => instruction.equipment && instruction.equipment.length > 0)
|
|
||||||
.map((instruction) => (
|
|
||||||
<div key={instruction.step_number} className="border border-gray-200 p-3 rounded">
|
|
||||||
<p className="font-medium text-sm mb-2">
|
|
||||||
Paso {instruction.step_number}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{instruction.equipment?.map((equipment, idx) => (
|
|
||||||
<Badge key={idx} className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
{renderRecipeHeader()}
|
|
||||||
{renderScalingControls()}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{renderIngredients()}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{renderInstructions()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderNutritionModal()}
|
|
||||||
{renderCostingModal()}
|
|
||||||
{renderEquipmentModal()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RecipeDisplay;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '../RecipeDisplay';
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,879 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Card,
|
|
||||||
Modal,
|
|
||||||
Tooltip
|
|
||||||
} from '../../ui';
|
|
||||||
import {
|
|
||||||
SalesRecord,
|
|
||||||
SalesChannel,
|
|
||||||
PaymentMethod,
|
|
||||||
SalesSortField,
|
|
||||||
SortOrder
|
|
||||||
} from '../../../types/sales.types';
|
|
||||||
import { salesService } from '../../../services/api/sales.service';
|
|
||||||
import { useSales } from '../../../hooks/api/useSales';
|
|
||||||
|
|
||||||
// Extended interface for orders
|
|
||||||
interface Order extends SalesRecord {
|
|
||||||
customer_name: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
order_notes?: string;
|
|
||||||
items_count: number;
|
|
||||||
status: OrderStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OrderStatus {
|
|
||||||
PENDIENTE = 'pendiente',
|
|
||||||
CONFIRMADO = 'confirmado',
|
|
||||||
EN_PREPARACION = 'en_preparacion',
|
|
||||||
LISTO = 'listo',
|
|
||||||
ENTREGADO = 'entregado',
|
|
||||||
CANCELADO = 'cancelado'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrdersTableProps {
|
|
||||||
tenantId?: string;
|
|
||||||
showActions?: boolean;
|
|
||||||
onOrderSelect?: (order: Order) => void;
|
|
||||||
onOrderUpdate?: (orderId: string, updates: Partial<Order>) => void;
|
|
||||||
initialFilters?: OrderFilters;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderFilters {
|
|
||||||
status?: OrderStatus;
|
|
||||||
customer_name?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
min_total?: number;
|
|
||||||
max_total?: number;
|
|
||||||
sales_channel?: SalesChannel;
|
|
||||||
payment_method?: PaymentMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BulkActions {
|
|
||||||
selectedOrders: string[];
|
|
||||||
action: 'change_status' | 'print_receipts' | 'export' | 'delete';
|
|
||||||
newStatus?: OrderStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusColors = {
|
|
||||||
[OrderStatus.PENDIENTE]: 'yellow',
|
|
||||||
[OrderStatus.CONFIRMADO]: 'blue',
|
|
||||||
[OrderStatus.EN_PREPARACION]: 'orange',
|
|
||||||
[OrderStatus.LISTO]: 'green',
|
|
||||||
[OrderStatus.ENTREGADO]: 'emerald',
|
|
||||||
[OrderStatus.CANCELADO]: 'red'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const StatusLabels = {
|
|
||||||
[OrderStatus.PENDIENTE]: 'Pendiente',
|
|
||||||
[OrderStatus.CONFIRMADO]: 'Confirmado',
|
|
||||||
[OrderStatus.EN_PREPARACION]: 'En Preparación',
|
|
||||||
[OrderStatus.LISTO]: 'Listo para Entrega',
|
|
||||||
[OrderStatus.ENTREGADO]: 'Entregado',
|
|
||||||
[OrderStatus.CANCELADO]: 'Cancelado'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const ChannelLabels = {
|
|
||||||
[SalesChannel.STORE_FRONT]: 'Tienda',
|
|
||||||
[SalesChannel.ONLINE]: 'Online',
|
|
||||||
[SalesChannel.PHONE_ORDER]: 'Teléfono',
|
|
||||||
[SalesChannel.DELIVERY]: 'Delivery',
|
|
||||||
[SalesChannel.CATERING]: 'Catering',
|
|
||||||
[SalesChannel.WHOLESALE]: 'Mayorista',
|
|
||||||
[SalesChannel.FARMERS_MARKET]: 'Mercado',
|
|
||||||
[SalesChannel.THIRD_PARTY]: 'Terceros'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const PaymentLabels = {
|
|
||||||
[PaymentMethod.CASH]: 'Efectivo',
|
|
||||||
[PaymentMethod.CREDIT_CARD]: 'Tarjeta Crédito',
|
|
||||||
[PaymentMethod.DEBIT_CARD]: 'Tarjeta Débito',
|
|
||||||
[PaymentMethod.DIGITAL_WALLET]: 'Wallet Digital',
|
|
||||||
[PaymentMethod.BANK_TRANSFER]: 'Transferencia',
|
|
||||||
[PaymentMethod.CHECK]: 'Cheque',
|
|
||||||
[PaymentMethod.STORE_CREDIT]: 'Crédito Tienda'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const OrdersTable: React.FC<OrdersTableProps> = ({
|
|
||||||
tenantId,
|
|
||||||
showActions = true,
|
|
||||||
onOrderSelect,
|
|
||||||
onOrderUpdate,
|
|
||||||
initialFilters = {}
|
|
||||||
}) => {
|
|
||||||
// State
|
|
||||||
const [orders, setOrders] = useState<Order[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Filters and sorting
|
|
||||||
const [filters, setFilters] = useState<OrderFilters>(initialFilters);
|
|
||||||
const [sortBy, setSortBy] = useState<SalesSortField>(SalesSortField.DATE);
|
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.DESC);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
// Selection and bulk actions
|
|
||||||
const [selectedOrders, setSelectedOrders] = useState<string[]>([]);
|
|
||||||
const [showBulkActions, setShowBulkActions] = useState(false);
|
|
||||||
const [bulkActionModal, setBulkActionModal] = useState(false);
|
|
||||||
const [bulkAction, setBulkAction] = useState<BulkActions['action'] | null>(null);
|
|
||||||
const [newBulkStatus, setNewBulkStatus] = useState<OrderStatus | null>(null);
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(20);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalOrders, setTotalOrders] = useState(0);
|
|
||||||
|
|
||||||
// Order details modal
|
|
||||||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
|
||||||
const [showOrderDetails, setShowOrderDetails] = useState(false);
|
|
||||||
|
|
||||||
// Sales hook
|
|
||||||
const { fetchSalesData } = useSales();
|
|
||||||
|
|
||||||
// Load orders
|
|
||||||
const loadOrders = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const queryParams = {
|
|
||||||
page: currentPage,
|
|
||||||
size: pageSize,
|
|
||||||
sort_by: sortBy,
|
|
||||||
sort_order: sortOrder,
|
|
||||||
...filters,
|
|
||||||
search: searchTerm || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simulate API call with mock data transformation
|
|
||||||
const response = await salesService.getSales(queryParams);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
// Transform sales records to orders (in real app this would come from orders API)
|
|
||||||
const transformedOrders: Order[] = response.data.items.map((sale: any, index: number) => ({
|
|
||||||
...sale,
|
|
||||||
customer_name: `Cliente ${index + 1}`,
|
|
||||||
customer_phone: `+34 ${Math.floor(Math.random() * 900000000 + 100000000)}`,
|
|
||||||
delivery_address: sale.sales_channel === SalesChannel.DELIVERY
|
|
||||||
? `Calle Ejemplo ${Math.floor(Math.random() * 100)}, Madrid`
|
|
||||||
: undefined,
|
|
||||||
order_notes: Math.random() > 0.7 ? 'Sin gluten' : undefined,
|
|
||||||
items_count: Math.floor(Math.random() * 5) + 1,
|
|
||||||
status: Object.values(OrderStatus)[Math.floor(Math.random() * Object.values(OrderStatus).length)],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setOrders(transformedOrders);
|
|
||||||
setTotalOrders(response.data.total);
|
|
||||||
setTotalPages(response.data.pages);
|
|
||||||
} else {
|
|
||||||
setError(response.error || 'Error al cargar pedidos');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Error de conexión al servidor');
|
|
||||||
console.error('Error loading orders:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentPage, pageSize, sortBy, sortOrder, filters, searchTerm]);
|
|
||||||
|
|
||||||
// Effects
|
|
||||||
useEffect(() => {
|
|
||||||
loadOrders();
|
|
||||||
}, [loadOrders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [filters, searchTerm]);
|
|
||||||
|
|
||||||
// Filtered and sorted orders
|
|
||||||
const filteredOrders = useMemo(() => {
|
|
||||||
let filtered = [...orders];
|
|
||||||
|
|
||||||
// Apply search filter
|
|
||||||
if (searchTerm) {
|
|
||||||
const searchLower = searchTerm.toLowerCase();
|
|
||||||
filtered = filtered.filter(order =>
|
|
||||||
order.customer_name.toLowerCase().includes(searchLower) ||
|
|
||||||
order.id.toLowerCase().includes(searchLower) ||
|
|
||||||
order.product_name.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [orders, searchTerm]);
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
key: 'select',
|
|
||||||
title: (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedOrders.length === filteredOrders.length && filteredOrders.length > 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedOrders(filteredOrders.map(order => order.id));
|
|
||||||
} else {
|
|
||||||
setSelectedOrders([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
render: (order: Order) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedOrders.includes(order.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedOrders(prev => [...prev, order.id]);
|
|
||||||
} else {
|
|
||||||
setSelectedOrders(prev => prev.filter(id => id !== order.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'id',
|
|
||||||
title: 'Nº Pedido',
|
|
||||||
sortable: true,
|
|
||||||
render: (order: Order) => (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(order);
|
|
||||||
setShowOrderDetails(true);
|
|
||||||
}}
|
|
||||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
|
||||||
>
|
|
||||||
#{order.id.slice(-8)}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'customer_name',
|
|
||||||
title: 'Cliente',
|
|
||||||
sortable: true,
|
|
||||||
render: (order: Order) => (
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{order.customer_name}</div>
|
|
||||||
{order.customer_phone && (
|
|
||||||
<div className="text-sm text-gray-500">{order.customer_phone}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'products',
|
|
||||||
title: 'Productos',
|
|
||||||
render: (order: Order) => (
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{order.product_name}</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{order.items_count} {order.items_count === 1 ? 'artículo' : 'artículos'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'total_revenue',
|
|
||||||
title: 'Total',
|
|
||||||
sortable: true,
|
|
||||||
render: (order: Order) => (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-semibold">€{order.total_revenue.toFixed(2)}</div>
|
|
||||||
{order.discount_applied > 0 && (
|
|
||||||
<div className="text-sm text-green-600">
|
|
||||||
-€{order.discount_applied.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: 'Estado',
|
|
||||||
sortable: true,
|
|
||||||
render: (order: Order) => (
|
|
||||||
<Badge color={StatusColors[order.status]} variant="soft">
|
|
||||||
{StatusLabels[order.status]}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sales_channel',
|
|
||||||
title: 'Canal',
|
|
||||||
render: (order: Order) => (
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{ChannelLabels[order.sales_channel]}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'date',
|
|
||||||
title: 'Fecha',
|
|
||||||
sortable: true,
|
|
||||||
render: (order: Order) => (
|
|
||||||
<div>
|
|
||||||
<div>{new Date(order.date).toLocaleDateString('es-ES')}</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date(order.created_at).toLocaleTimeString('es-ES', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (showActions) {
|
|
||||||
columns.push({
|
|
||||||
key: 'actions',
|
|
||||||
title: 'Acciones',
|
|
||||||
render: (order: Order) => (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Tooltip content="Ver detalles">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(order);
|
|
||||||
setShowOrderDetails(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip content="Cambiar estado">
|
|
||||||
<Select
|
|
||||||
size="sm"
|
|
||||||
value={order.status}
|
|
||||||
onChange={(value) => handleStatusChange(order.id, value as OrderStatus)}
|
|
||||||
options={Object.values(OrderStatus).map(status => ({
|
|
||||||
value: status,
|
|
||||||
label: StatusLabels[status]
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{order.status !== OrderStatus.CANCELADO && (
|
|
||||||
<Tooltip content="Imprimir recibo">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handlePrintReceipt(order.id)}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
|
|
||||||
try {
|
|
||||||
// Update locally first
|
|
||||||
setOrders(prev => prev.map(order =>
|
|
||||||
order.id === orderId ? { ...order, status: newStatus } : order
|
|
||||||
));
|
|
||||||
|
|
||||||
// Call API
|
|
||||||
onOrderUpdate?.(orderId, { status: newStatus });
|
|
||||||
|
|
||||||
// In real app, make API call here
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating order status:', error);
|
|
||||||
// Revert on error
|
|
||||||
loadOrders();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrintReceipt = (orderId: string) => {
|
|
||||||
// In real app, generate and print receipt
|
|
||||||
console.log('Printing receipt for order:', orderId);
|
|
||||||
window.print();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkAction = async () => {
|
|
||||||
if (!bulkAction || selectedOrders.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (bulkAction) {
|
|
||||||
case 'change_status':
|
|
||||||
if (newBulkStatus) {
|
|
||||||
setOrders(prev => prev.map(order =>
|
|
||||||
selectedOrders.includes(order.id)
|
|
||||||
? { ...order, status: newBulkStatus }
|
|
||||||
: order
|
|
||||||
));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'print_receipts':
|
|
||||||
selectedOrders.forEach(handlePrintReceipt);
|
|
||||||
break;
|
|
||||||
case 'export':
|
|
||||||
handleExportOrders(selectedOrders);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
setOrders(prev => prev.filter(order => !selectedOrders.includes(order.id)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedOrders([]);
|
|
||||||
setBulkActionModal(false);
|
|
||||||
setBulkAction(null);
|
|
||||||
setNewBulkStatus(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error performing bulk action:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportOrders = async (orderIds?: string[]) => {
|
|
||||||
try {
|
|
||||||
const exportData = orders
|
|
||||||
.filter(order => !orderIds || orderIds.includes(order.id))
|
|
||||||
.map(order => ({
|
|
||||||
'Nº Pedido': order.id,
|
|
||||||
'Cliente': order.customer_name,
|
|
||||||
'Teléfono': order.customer_phone,
|
|
||||||
'Productos': order.product_name,
|
|
||||||
'Cantidad': order.quantity_sold,
|
|
||||||
'Total': order.total_revenue,
|
|
||||||
'Estado': StatusLabels[order.status],
|
|
||||||
'Canal': ChannelLabels[order.sales_channel],
|
|
||||||
'Fecha': new Date(order.date).toLocaleDateString('es-ES'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const csv = [
|
|
||||||
Object.keys(exportData[0]).join(','),
|
|
||||||
...exportData.map(row => Object.values(row).join(','))
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `pedidos_${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting orders:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Pedidos</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{totalOrders} pedidos encontrados
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Button onClick={() => handleExportOrders()} variant="outline" size="sm">
|
|
||||||
<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>
|
|
||||||
<Button onClick={() => loadOrders()} size="sm">
|
|
||||||
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Actualizar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Input
|
|
||||||
label="Buscar pedidos"
|
|
||||||
placeholder="Cliente, nº pedido o producto..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Estado"
|
|
||||||
value={filters.status || ''}
|
|
||||||
onChange={(value) => setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: value ? value as OrderStatus : undefined
|
|
||||||
}))}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'Todos los estados' },
|
|
||||||
...Object.values(OrderStatus).map(status => ({
|
|
||||||
value: status,
|
|
||||||
label: StatusLabels[status]
|
|
||||||
}))
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Canal de venta"
|
|
||||||
value={filters.sales_channel || ''}
|
|
||||||
onChange={(value) => setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
sales_channel: value ? value as SalesChannel : undefined
|
|
||||||
}))}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'Todos los canales' },
|
|
||||||
...Object.values(SalesChannel).map(channel => ({
|
|
||||||
value: channel,
|
|
||||||
label: ChannelLabels[channel]
|
|
||||||
}))
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Input
|
|
||||||
label="Total mínimo"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={filters.min_total || ''}
|
|
||||||
onChange={(e) => setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
min_total: e.target.value ? parseFloat(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
placeholder="€0.00"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Total máximo"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={filters.max_total || ''}
|
|
||||||
onChange={(e) => setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
max_total: e.target.value ? parseFloat(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
placeholder="€999.99"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Mostrando {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalOrders)} de {totalOrders}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={pageSize.toString()}
|
|
||||||
onChange={(value) => setPageSize(parseInt(value))}
|
|
||||||
options={[
|
|
||||||
{ value: '10', label: '10 por página' },
|
|
||||||
{ value: '20', label: '20 por página' },
|
|
||||||
{ value: '50', label: '50 por página' },
|
|
||||||
{ value: '100', label: '100 por página' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
|
||||||
{selectedOrders.length > 0 && (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{selectedOrders.length} pedidos seleccionados
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setBulkAction('change_status');
|
|
||||||
setBulkActionModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cambiar Estado
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setBulkAction('print_receipts');
|
|
||||||
handleBulkAction();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Imprimir Recibos
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleExportOrders(selectedOrders)}
|
|
||||||
>
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSelectedOrders([])}
|
|
||||||
>
|
|
||||||
Limpiar Selección
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<Card>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={filteredOrders}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage="No se encontraron pedidos"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setCurrentPage(prev => prev - 1)}
|
|
||||||
>
|
|
||||||
Anterior
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
||||||
.filter(page =>
|
|
||||||
page === 1 ||
|
|
||||||
page === totalPages ||
|
|
||||||
Math.abs(page - currentPage) <= 2
|
|
||||||
)
|
|
||||||
.map((page, index, array) => (
|
|
||||||
<React.Fragment key={page}>
|
|
||||||
{index > 0 && array[index - 1] !== page - 1 && (
|
|
||||||
<span className="px-3 py-1 text-gray-400">...</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={currentPage === page ? 'primary' : 'outline'}
|
|
||||||
onClick={() => setCurrentPage(page)}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</Button>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
onClick={() => setCurrentPage(prev => prev + 1)}
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Order Details Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showOrderDetails}
|
|
||||||
onClose={() => setShowOrderDetails(false)}
|
|
||||||
title={`Pedido #${selectedOrder?.id.slice(-8)}`}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{selectedOrder && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Información del Cliente</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div><span className="text-gray-600">Nombre:</span> {selectedOrder.customer_name}</div>
|
|
||||||
{selectedOrder.customer_phone && (
|
|
||||||
<div><span className="text-gray-600">Teléfono:</span> {selectedOrder.customer_phone}</div>
|
|
||||||
)}
|
|
||||||
{selectedOrder.delivery_address && (
|
|
||||||
<div><span className="text-gray-600">Dirección:</span> {selectedOrder.delivery_address}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Detalles del Pedido</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div><span className="text-gray-600">Estado:</span>
|
|
||||||
<Badge color={StatusColors[selectedOrder.status]} variant="soft" className="ml-2">
|
|
||||||
{StatusLabels[selectedOrder.status]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div><span className="text-gray-600">Canal:</span> {ChannelLabels[selectedOrder.sales_channel]}</div>
|
|
||||||
<div><span className="text-gray-600">Fecha:</span> {new Date(selectedOrder.date).toLocaleDateString('es-ES')}</div>
|
|
||||||
{selectedOrder.payment_method && (
|
|
||||||
<div><span className="text-gray-600">Pago:</span> {PaymentLabels[selectedOrder.payment_method]}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Productos</h4>
|
|
||||||
<div className="border rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{selectedOrder.product_name}</div>
|
|
||||||
{selectedOrder.category && (
|
|
||||||
<div className="text-sm text-gray-600">{selectedOrder.category}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-medium">€{selectedOrder.unit_price.toFixed(2)} × {selectedOrder.quantity_sold}</div>
|
|
||||||
<div className="text-sm text-gray-600">€{selectedOrder.total_revenue.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedOrder.order_notes && (
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Notas del Pedido</h4>
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm">
|
|
||||||
{selectedOrder.order_notes}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<div className="flex justify-between items-center text-lg font-semibold">
|
|
||||||
<span>Total del Pedido:</span>
|
|
||||||
<span>€{selectedOrder.total_revenue.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
{selectedOrder.discount_applied > 0 && (
|
|
||||||
<div className="flex justify-between items-center text-sm text-green-600">
|
|
||||||
<span>Descuento aplicado:</span>
|
|
||||||
<span>-€{selectedOrder.discount_applied.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedOrder.tax_amount > 0 && (
|
|
||||||
<div className="flex justify-between items-center text-sm text-gray-600">
|
|
||||||
<span>IVA incluido:</span>
|
|
||||||
<span>€{selectedOrder.tax_amount.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handlePrintReceipt(selectedOrder.id)}
|
|
||||||
>
|
|
||||||
Imprimir Recibo
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
onOrderSelect?.(selectedOrder);
|
|
||||||
setShowOrderDetails(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Editar Pedido
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Bulk Action Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={bulkActionModal}
|
|
||||||
onClose={() => setBulkActionModal(false)}
|
|
||||||
title="Acción masiva"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>
|
|
||||||
Vas a realizar una acción sobre {selectedOrders.length} pedidos seleccionados.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{bulkAction === 'change_status' && (
|
|
||||||
<Select
|
|
||||||
label="Nuevo estado"
|
|
||||||
value={newBulkStatus || ''}
|
|
||||||
onChange={(value) => setNewBulkStatus(value as OrderStatus)}
|
|
||||||
options={Object.values(OrderStatus).map(status => ({
|
|
||||||
value: status,
|
|
||||||
label: StatusLabels[status]
|
|
||||||
}))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<Button variant="outline" onClick={() => setBulkActionModal(false)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleBulkAction}
|
|
||||||
disabled={bulkAction === 'change_status' && !newBulkStatus}
|
|
||||||
>
|
|
||||||
Confirmar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<svg className="w-5 h-5 text-red-400 mr-2 mt-0.5" 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>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
|
||||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
Cerrar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrdersTable;
|
|
||||||
Reference in New Issue
Block a user