ADD new frontend
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Insights de IA</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Insights de IA en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPage;
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const FinancialReportsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reportes Financieros</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Reportes financieros en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancialReportsPage;
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const PerformanceKPIsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">KPIs de Rendimiento</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">KPIs de rendimiento en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceKPIsPage;
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
||||
|
||||
const ProductionReportsPage: React.FC = () => {
|
||||
const { getProductionLabel } = useBakeryType();
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reportes de {getProductionLabel()}</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Reportes de {getProductionLabel().toLowerCase()} en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionReportsPage;
|
||||
@@ -1,120 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TrendingUp, DollarSign, ShoppingCart, Calendar } from 'lucide-react';
|
||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
||||
|
||||
const SalesAnalyticsPage: React.FC = () => {
|
||||
const { isIndividual, isCentral } = useBakeryType();
|
||||
const [timeRange, setTimeRange] = useState('week');
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Análisis de Ventas</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{isIndividual
|
||||
? 'Analiza el rendimiento de ventas de tu panadería'
|
||||
: 'Analiza el rendimiento de ventas de todos tus puntos de venta'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-6">
|
||||
<div className="flex space-x-2">
|
||||
{['day', 'week', 'month', 'quarter'].map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
timeRange === range
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{range === 'day' && 'Hoy'}
|
||||
{range === 'week' && 'Esta Semana'}
|
||||
{range === 'month' && 'Este Mes'}
|
||||
{range === 'quarter' && 'Este Trimestre'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-2xl font-bold text-gray-900">€2,847</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<ShoppingCart className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{isIndividual ? 'Productos Vendidos' : 'Productos Distribuidos'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">1,429</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Crecimiento</p>
|
||||
<p className="text-2xl font-bold text-gray-900">+12.5%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="h-4 w-4 text-yellow-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Días Activos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">6/7</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts placeholder */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Tendencia de Ventas
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">Gráfico de tendencias aquí</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{isIndividual ? 'Productos Más Vendidos' : 'Productos Más Distribuidos'}
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">Gráfico de productos aquí</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsPage;
|
||||
352
frontend/src/pages/app/DashboardPage.tsx
Normal file
352
frontend/src/pages/app/DashboardPage.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, AlertTriangle, CheckCircle, Clock, DollarSign } from 'lucide-react';
|
||||
import { Card, Badge } from '../../components/ui';
|
||||
import { PageHeader } from '../../components/layout';
|
||||
import { DashboardCard, KPIWidget, QuickActions, RecentActivity, ActivityType, ActivityStatus } from '../../components/domain/dashboard';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const kpiData = [
|
||||
{
|
||||
title: 'Ventas Hoy',
|
||||
value: {
|
||||
current: 1247,
|
||||
previous: 1112,
|
||||
format: 'currency' as const,
|
||||
prefix: '€'
|
||||
},
|
||||
trend: {
|
||||
direction: 'up' as const,
|
||||
value: 12,
|
||||
isPositive: true,
|
||||
comparisonPeriod: 'vs ayer'
|
||||
},
|
||||
icon: <DollarSign className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Órdenes Pendientes',
|
||||
value: {
|
||||
current: 23,
|
||||
previous: 24,
|
||||
format: 'number' as const
|
||||
},
|
||||
trend: {
|
||||
direction: 'down' as const,
|
||||
value: 4.2,
|
||||
isPositive: false,
|
||||
comparisonPeriod: 'vs ayer'
|
||||
},
|
||||
icon: <Clock className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Productos Vendidos',
|
||||
value: {
|
||||
current: 156,
|
||||
previous: 144,
|
||||
format: 'number' as const
|
||||
},
|
||||
trend: {
|
||||
direction: 'up' as const,
|
||||
value: 8.3,
|
||||
isPositive: true,
|
||||
comparisonPeriod: 'vs ayer'
|
||||
},
|
||||
icon: <CheckCircle className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Stock Crítico',
|
||||
value: {
|
||||
current: 4,
|
||||
previous: 2,
|
||||
format: 'number' as const
|
||||
},
|
||||
trend: {
|
||||
direction: 'up' as const,
|
||||
value: 100,
|
||||
isPositive: false,
|
||||
comparisonPeriod: 'vs ayer'
|
||||
},
|
||||
status: 'warning' as const,
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
id: 'production',
|
||||
title: 'Nueva Orden de Producción',
|
||||
description: 'Crear nueva orden de producción',
|
||||
icon: <TrendingUp className="w-6 h-6" />,
|
||||
onClick: () => window.location.href = '/app/operations/production',
|
||||
href: '/app/operations/production'
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
title: 'Gestionar Inventario',
|
||||
description: 'Administrar stock de productos',
|
||||
icon: <CheckCircle className="w-6 h-6" />,
|
||||
onClick: () => window.location.href = '/app/operations/inventory',
|
||||
href: '/app/operations/inventory'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
title: 'Ver Ventas',
|
||||
description: 'Analizar ventas y reportes',
|
||||
icon: <DollarSign className="w-6 h-6" />,
|
||||
onClick: () => window.location.href = '/app/analytics/sales',
|
||||
href: '/app/analytics/sales'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Configuración',
|
||||
description: 'Ajustar configuración del sistema',
|
||||
icon: <AlertTriangle className="w-6 h-6" />,
|
||||
onClick: () => window.location.href = '/app/settings',
|
||||
href: '/app/settings'
|
||||
},
|
||||
];
|
||||
|
||||
const recentActivities = [
|
||||
{
|
||||
id: '1',
|
||||
type: ActivityType.PRODUCTION,
|
||||
title: 'Orden de producción completada',
|
||||
description: 'Pan de Molde Integral - 20 unidades',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
status: ActivityStatus.SUCCESS,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: ActivityType.INVENTORY,
|
||||
title: 'Stock bajo detectado',
|
||||
description: 'Levadura fresca necesita reposición',
|
||||
timestamp: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
||||
status: ActivityStatus.WARNING,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: ActivityType.SALES,
|
||||
title: 'Venta registrada',
|
||||
description: '€45.50 - Croissants y café',
|
||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
status: ActivityStatus.INFO,
|
||||
},
|
||||
];
|
||||
|
||||
const productionStatus = {
|
||||
today: {
|
||||
target: 150,
|
||||
completed: 95,
|
||||
inProgress: 18,
|
||||
pending: 37,
|
||||
},
|
||||
efficiency: 85,
|
||||
};
|
||||
|
||||
const salesData = {
|
||||
today: 1247,
|
||||
yesterday: 1112,
|
||||
thisWeek: 8934,
|
||||
thisMonth: 35678,
|
||||
};
|
||||
|
||||
const inventoryAlerts = [
|
||||
{ item: 'Levadura Fresca', current: 2, min: 5, status: 'critical' },
|
||||
{ item: 'Harina Integral', current: 8, min: 10, status: 'low' },
|
||||
{ item: 'Mantequilla', current: 15, min: 20, status: 'low' },
|
||||
];
|
||||
|
||||
const topProducts = [
|
||||
{ name: 'Pan de Molde', sold: 45, revenue: 202.50 },
|
||||
{ name: 'Croissants', sold: 32, revenue: 192.00 },
|
||||
{ name: 'Baguettes', sold: 28, revenue: 84.00 },
|
||||
{ name: 'Magdalenas', sold: 24, revenue: 72.00 },
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Panel de Control"
|
||||
description="Vista general de tu panadería"
|
||||
/>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpiData.map((kpi, index) => (
|
||||
<KPIWidget
|
||||
key={index}
|
||||
title={kpi.title}
|
||||
value={kpi.value}
|
||||
trend={kpi.trend}
|
||||
icon={kpi.icon}
|
||||
status={kpi.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Production Status */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Estado de Producción</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Progreso del Día</span>
|
||||
<span className="text-sm font-medium">
|
||||
{productionStatus.today.completed} / {productionStatus.today.target}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-[var(--color-info)] h-2 rounded-full"
|
||||
style={{
|
||||
width: `${(productionStatus.today.completed / productionStatus.today.target) * 100}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">{productionStatus.today.completed}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Completado</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{productionStatus.today.inProgress}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">En Proceso</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{productionStatus.today.pending}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Pendiente</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Eficiencia</span>
|
||||
<span className="text-sm font-medium text-purple-600">{productionStatus.efficiency}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Sales Summary */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen de Ventas</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Hoy</span>
|
||||
<span className="text-lg font-semibold text-[var(--color-success)]">€{salesData.today.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Ayer</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium mr-2">€{salesData.yesterday.toLocaleString()}</span>
|
||||
{salesData.today > salesData.yesterday ? (
|
||||
<TrendingUp className="h-4 w-4 text-[var(--color-success)]" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-[var(--color-error)]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Esta Semana</span>
|
||||
<span className="text-sm font-medium">€{salesData.thisWeek.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Este Mes</span>
|
||||
<span className="text-sm font-medium">€{salesData.thisMonth.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-[var(--text-secondary)]">Crecimiento vs ayer</p>
|
||||
<p className="text-lg font-semibold text-[var(--color-success)]">
|
||||
+{(((salesData.today - salesData.yesterday) / salesData.yesterday) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas de Inventario</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{inventoryAlerts.map((alert, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--color-error)]/10 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{alert.item}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Stock: {alert.current} / Mín: {alert.min}</p>
|
||||
</div>
|
||||
<Badge variant={alert.status === 'critical' ? 'red' : 'yellow'}>
|
||||
{alert.status === 'critical' ? 'Crítico' : 'Bajo'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<button className="w-full text-sm text-[var(--color-info)] hover:text-[var(--color-info)] font-medium">
|
||||
Ver Todo el Inventario →
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Productos Más Vendidos</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{topProducts.map((product, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)] w-6">{index + 1}.</span>
|
||||
<span className="text-sm text-[var(--text-primary)]">{product.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{product.sold} unidades</p>
|
||||
<p className="text-xs text-[var(--color-success)]">€{product.revenue.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<button className="w-full text-sm text-[var(--color-info)] hover:text-[var(--color-info)] font-medium">
|
||||
Ver Análisis Completo →
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Actividad Reciente</h3>
|
||||
|
||||
<RecentActivity activities={recentActivities} />
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<button className="w-full text-sm text-[var(--color-info)] hover:text-[var(--color-info)] font-medium">
|
||||
Ver Toda la Actividad →
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Acciones Rápidas</h3>
|
||||
<QuickActions actions={quickActions} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
313
frontend/src/pages/app/analytics/ai-insights/AIInsightsPage.tsx
Normal file
313
frontend/src/pages/app/analytics/ai-insights/AIInsightsPage.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Brain, TrendingUp, AlertTriangle, Lightbulb, Target, Zap, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const insights = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
priority: 'high',
|
||||
title: 'Optimización de Producción de Croissants',
|
||||
description: 'La demanda de croissants aumenta un 23% los viernes. Recomendamos incrementar la producción en 15 unidades.',
|
||||
impact: 'Aumento estimado de ingresos: €180/semana',
|
||||
confidence: 87,
|
||||
category: 'production',
|
||||
timestamp: '2024-01-26 09:30',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
currentProduction: 45,
|
||||
recommendedProduction: 60,
|
||||
expectedIncrease: '+23%'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'alert',
|
||||
priority: 'medium',
|
||||
title: 'Patrón de Compra en Tardes',
|
||||
description: 'Los clientes compran más productos salados después de las 16:00. Considera promocionar empanadas durante estas horas.',
|
||||
impact: 'Potencial aumento de ventas: 12%',
|
||||
confidence: 92,
|
||||
category: 'sales',
|
||||
timestamp: '2024-01-26 08:45',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
afternoonSales: '+15%',
|
||||
savoryProducts: '68%',
|
||||
conversionRate: '12.3%'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'prediction',
|
||||
priority: 'high',
|
||||
title: 'Predicción de Demanda de San Valentín',
|
||||
description: 'Se espera un incremento del 40% en la demanda de productos de repostería especiales entre el 10-14 de febrero.',
|
||||
impact: 'Preparar stock adicional de ingredientes premium',
|
||||
confidence: 94,
|
||||
category: 'forecasting',
|
||||
timestamp: '2024-01-26 07:15',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
expectedIncrease: '+40%',
|
||||
daysAhead: 18,
|
||||
recommendedPrep: '3 días'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
priority: 'low',
|
||||
title: 'Optimización de Inventario de Harina',
|
||||
description: 'El consumo de harina integral ha disminuido 8% este mes. Considera ajustar las órdenes de compra.',
|
||||
impact: 'Reducción de desperdicios: €45/mes',
|
||||
confidence: 78,
|
||||
category: 'inventory',
|
||||
timestamp: '2024-01-25 16:20',
|
||||
actionable: false,
|
||||
metrics: {
|
||||
consumption: '-8%',
|
||||
currentStock: '45kg',
|
||||
recommendedOrder: '25kg'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'insight',
|
||||
priority: 'medium',
|
||||
title: 'Análisis de Satisfacción del Cliente',
|
||||
description: 'Los clientes valoran más la frescura (95%) que el precio (67%). Enfoque en destacar la calidad artesanal.',
|
||||
impact: 'Mejorar estrategia de marketing',
|
||||
confidence: 89,
|
||||
category: 'customer',
|
||||
timestamp: '2024-01-25 14:30',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
freshnessScore: '95%',
|
||||
priceScore: '67%',
|
||||
qualityScore: '91%'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas las Categorías', count: insights.length },
|
||||
{ value: 'production', label: 'Producción', count: insights.filter(i => i.category === 'production').length },
|
||||
{ value: 'sales', label: 'Ventas', count: insights.filter(i => i.category === 'sales').length },
|
||||
{ value: 'forecasting', label: 'Pronósticos', count: insights.filter(i => i.category === 'forecasting').length },
|
||||
{ value: 'inventory', label: 'Inventario', count: insights.filter(i => i.category === 'inventory').length },
|
||||
{ value: 'customer', label: 'Clientes', count: insights.filter(i => i.category === 'customer').length },
|
||||
];
|
||||
|
||||
const aiMetrics = {
|
||||
totalInsights: insights.length,
|
||||
actionableInsights: insights.filter(i => i.actionable).length,
|
||||
averageConfidence: Math.round(insights.reduce((sum, i) => sum + i.confidence, 0) / insights.length),
|
||||
highPriorityInsights: insights.filter(i => i.priority === 'high').length,
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'optimization': return <Target {...iconProps} />;
|
||||
case 'alert': return <AlertTriangle {...iconProps} />;
|
||||
case 'prediction': return <TrendingUp {...iconProps} />;
|
||||
case 'recommendation': return <Lightbulb {...iconProps} />;
|
||||
case 'insight': return <Brain {...iconProps} />;
|
||||
default: return <Brain {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'optimization': return 'bg-[var(--color-info)]/10 text-[var(--color-info)]';
|
||||
case 'alert': return 'bg-[var(--color-error)]/10 text-[var(--color-error)]';
|
||||
case 'prediction': return 'bg-purple-100 text-purple-800';
|
||||
case 'recommendation': return 'bg-[var(--color-success)]/10 text-[var(--color-success)]';
|
||||
case 'insight': return 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]';
|
||||
default: return 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInsights = selectedCategory === 'all'
|
||||
? insights
|
||||
: insights.filter(insight => insight.category === selectedCategory);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Inteligencia Artificial"
|
||||
description="Insights inteligentes y recomendaciones automáticas para optimizar tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Insights</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{aiMetrics.totalInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Brain className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Accionables</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{aiMetrics.actionableInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Confianza Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{aiMetrics.averageConfidence}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Target className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Alta Prioridad</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-error)]">{aiMetrics.highPriorityInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="space-y-4">
|
||||
{filteredInsights.map((insight) => (
|
||||
<Card key={insight.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg ${getTypeColor(insight.type)}`}>
|
||||
{getTypeIcon(insight.type)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={getPriorityColor(insight.priority)}>
|
||||
{insight.priority === 'high' ? 'Alta' : insight.priority === 'medium' ? 'Media' : 'Baja'} Prioridad
|
||||
</Badge>
|
||||
<Badge variant="gray">{insight.confidence}% confianza</Badge>
|
||||
{insight.actionable && (
|
||||
<Badge variant="blue">Accionable</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">{insight.title}</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-3">{insight.description}</p>
|
||||
<p className="text-sm font-medium text-[var(--color-success)] mb-4">{insight.impact}</p>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{Object.entries(insight.metrics).map(([key, value]) => (
|
||||
<div key={key} className="bg-[var(--bg-secondary)] p-3 rounded-lg">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{insight.timestamp}</p>
|
||||
{insight.actionable && (
|
||||
<Button size="sm">
|
||||
Aplicar Recomendación
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredInsights.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Brain className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No hay insights disponibles</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
No se encontraron insights para la categoría seleccionada.
|
||||
</p>
|
||||
<Button onClick={handleRefresh}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Generar Nuevos Insights
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPage;
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Brain, TrendingUp, AlertTriangle, Lightbulb, Target, Zap, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const insights = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
priority: 'high',
|
||||
title: 'Optimización de Producción de Croissants',
|
||||
description: 'La demanda de croissants aumenta un 23% los viernes. Recomendamos incrementar la producción en 15 unidades.',
|
||||
impact: 'Aumento estimado de ingresos: €180/semana',
|
||||
confidence: 87,
|
||||
category: 'production',
|
||||
timestamp: '2024-01-26 09:30',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
currentProduction: 45,
|
||||
recommendedProduction: 60,
|
||||
expectedIncrease: '+23%'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'alert',
|
||||
priority: 'medium',
|
||||
title: 'Patrón de Compra en Tardes',
|
||||
description: 'Los clientes compran más productos salados después de las 16:00. Considera promocionar empanadas durante estas horas.',
|
||||
impact: 'Potencial aumento de ventas: 12%',
|
||||
confidence: 92,
|
||||
category: 'sales',
|
||||
timestamp: '2024-01-26 08:45',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
afternoonSales: '+15%',
|
||||
savoryProducts: '68%',
|
||||
conversionRate: '12.3%'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'prediction',
|
||||
priority: 'high',
|
||||
title: 'Predicción de Demanda de San Valentín',
|
||||
description: 'Se espera un incremento del 40% en la demanda de productos de repostería especiales entre el 10-14 de febrero.',
|
||||
impact: 'Preparar stock adicional de ingredientes premium',
|
||||
confidence: 94,
|
||||
category: 'forecasting',
|
||||
timestamp: '2024-01-26 07:15',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
expectedIncrease: '+40%',
|
||||
daysAhead: 18,
|
||||
recommendedPrep: '3 días'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
priority: 'low',
|
||||
title: 'Optimización de Inventario de Harina',
|
||||
description: 'El consumo de harina integral ha disminuido 8% este mes. Considera ajustar las órdenes de compra.',
|
||||
impact: 'Reducción de desperdicios: €45/mes',
|
||||
confidence: 78,
|
||||
category: 'inventory',
|
||||
timestamp: '2024-01-25 16:20',
|
||||
actionable: false,
|
||||
metrics: {
|
||||
consumption: '-8%',
|
||||
currentStock: '45kg',
|
||||
recommendedOrder: '25kg'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'insight',
|
||||
priority: 'medium',
|
||||
title: 'Análisis de Satisfacción del Cliente',
|
||||
description: 'Los clientes valoran más la frescura (95%) que el precio (67%). Enfoque en destacar la calidad artesanal.',
|
||||
impact: 'Mejorar estrategia de marketing',
|
||||
confidence: 89,
|
||||
category: 'customer',
|
||||
timestamp: '2024-01-25 14:30',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
freshnessScore: '95%',
|
||||
priceScore: '67%',
|
||||
qualityScore: '91%'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas las Categorías', count: insights.length },
|
||||
{ value: 'production', label: 'Producción', count: insights.filter(i => i.category === 'production').length },
|
||||
{ value: 'sales', label: 'Ventas', count: insights.filter(i => i.category === 'sales').length },
|
||||
{ value: 'forecasting', label: 'Pronósticos', count: insights.filter(i => i.category === 'forecasting').length },
|
||||
{ value: 'inventory', label: 'Inventario', count: insights.filter(i => i.category === 'inventory').length },
|
||||
{ value: 'customer', label: 'Clientes', count: insights.filter(i => i.category === 'customer').length },
|
||||
];
|
||||
|
||||
const aiMetrics = {
|
||||
totalInsights: insights.length,
|
||||
actionableInsights: insights.filter(i => i.actionable).length,
|
||||
averageConfidence: Math.round(insights.reduce((sum, i) => sum + i.confidence, 0) / insights.length),
|
||||
highPriorityInsights: insights.filter(i => i.priority === 'high').length,
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'optimization': return <Target {...iconProps} />;
|
||||
case 'alert': return <AlertTriangle {...iconProps} />;
|
||||
case 'prediction': return <TrendingUp {...iconProps} />;
|
||||
case 'recommendation': return <Lightbulb {...iconProps} />;
|
||||
case 'insight': return <Brain {...iconProps} />;
|
||||
default: return <Brain {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'optimization': return 'bg-blue-100 text-blue-800';
|
||||
case 'alert': return 'bg-red-100 text-red-800';
|
||||
case 'prediction': return 'bg-purple-100 text-purple-800';
|
||||
case 'recommendation': return 'bg-green-100 text-green-800';
|
||||
case 'insight': return 'bg-orange-100 text-orange-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInsights = selectedCategory === 'all'
|
||||
? insights
|
||||
: insights.filter(insight => insight.category === selectedCategory);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Inteligencia Artificial"
|
||||
description="Insights inteligentes y recomendaciones automáticas para optimizar tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Insights</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{aiMetrics.totalInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Brain className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Accionables</p>
|
||||
<p className="text-3xl font-bold text-green-600">{aiMetrics.actionableInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Confianza Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{aiMetrics.averageConfidence}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Target className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Alta Prioridad</p>
|
||||
<p className="text-3xl font-bold text-red-600">{aiMetrics.highPriorityInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="space-y-4">
|
||||
{filteredInsights.map((insight) => (
|
||||
<Card key={insight.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg ${getTypeColor(insight.type)}`}>
|
||||
{getTypeIcon(insight.type)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={getPriorityColor(insight.priority)}>
|
||||
{insight.priority === 'high' ? 'Alta' : insight.priority === 'medium' ? 'Media' : 'Baja'} Prioridad
|
||||
</Badge>
|
||||
<Badge variant="gray">{insight.confidence}% confianza</Badge>
|
||||
{insight.actionable && (
|
||||
<Badge variant="blue">Accionable</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{insight.title}</h3>
|
||||
<p className="text-gray-700 mb-3">{insight.description}</p>
|
||||
<p className="text-sm font-medium text-green-600 mb-4">{insight.impact}</p>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{Object.entries(insight.metrics).map(([key, value]) => (
|
||||
<div key={key} className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500">{insight.timestamp}</p>
|
||||
{insight.actionable && (
|
||||
<Button size="sm">
|
||||
Aplicar Recomendación
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredInsights.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Brain className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay insights disponibles</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
No se encontraron insights para la categoría seleccionada.
|
||||
</p>
|
||||
<Button onClick={handleRefresh}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Generar Nuevos Insights
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPage;
|
||||
1
frontend/src/pages/app/analytics/ai-insights/index.ts
Normal file
1
frontend/src/pages/app/analytics/ai-insights/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AIInsightsPage } from './AIInsightsPage';
|
||||
385
frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx
Normal file
385
frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings } from 'lucide-react';
|
||||
import { Button, Card, Badge, Select } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting';
|
||||
|
||||
const ForecastingPage: React.FC = () => {
|
||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||
const [forecastPeriod, setForecastPeriod] = useState('7');
|
||||
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
||||
|
||||
const forecastData = {
|
||||
accuracy: 92,
|
||||
totalDemand: 1247,
|
||||
growthTrend: 8.5,
|
||||
seasonalityFactor: 1.15,
|
||||
};
|
||||
|
||||
const products = [
|
||||
{ id: 'all', name: 'Todos los productos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
];
|
||||
|
||||
const periods = [
|
||||
{ value: '7', label: '7 días' },
|
||||
{ value: '14', label: '14 días' },
|
||||
{ value: '30', label: '30 días' },
|
||||
{ value: '90', label: '3 meses' },
|
||||
];
|
||||
|
||||
const mockForecasts = [
|
||||
{
|
||||
id: '1',
|
||||
product: 'Pan de Molde Integral',
|
||||
currentStock: 25,
|
||||
forecastDemand: 45,
|
||||
recommendedProduction: 50,
|
||||
confidence: 95,
|
||||
trend: 'up',
|
||||
stockoutRisk: 'low',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
product: 'Croissants de Mantequilla',
|
||||
currentStock: 18,
|
||||
forecastDemand: 32,
|
||||
recommendedProduction: 35,
|
||||
confidence: 88,
|
||||
trend: 'stable',
|
||||
stockoutRisk: 'medium',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
product: 'Baguettes Francesas',
|
||||
currentStock: 12,
|
||||
forecastDemand: 28,
|
||||
recommendedProduction: 30,
|
||||
confidence: 91,
|
||||
trend: 'down',
|
||||
stockoutRisk: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
const alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'stockout',
|
||||
product: 'Baguettes Francesas',
|
||||
message: 'Alto riesgo de agotamiento en las próximas 24h',
|
||||
severity: 'high',
|
||||
recommendation: 'Incrementar producción en 15 unidades',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'overstock',
|
||||
product: 'Magdalenas',
|
||||
message: 'Probable exceso de stock para mañana',
|
||||
severity: 'medium',
|
||||
recommendation: 'Reducir producción en 20%',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'weather',
|
||||
product: 'Todos',
|
||||
message: 'Lluvia prevista - incremento esperado en demanda de bollería',
|
||||
severity: 'info',
|
||||
recommendation: 'Aumentar producción de productos de interior en 10%',
|
||||
},
|
||||
];
|
||||
|
||||
const weatherImpact = {
|
||||
today: 'sunny',
|
||||
temperature: 22,
|
||||
demandFactor: 0.95,
|
||||
affectedCategories: ['helados', 'bebidas frías'],
|
||||
};
|
||||
|
||||
const seasonalInsights = [
|
||||
{ period: 'Mañana', factor: 1.2, products: ['Pan', 'Bollería'] },
|
||||
{ period: 'Tarde', factor: 0.8, products: ['Tartas', 'Dulces'] },
|
||||
{ period: 'Fin de semana', factor: 1.4, products: ['Tartas especiales'] },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4 text-[var(--color-success)]" />;
|
||||
case 'down':
|
||||
return <TrendingUp className="h-4 w-4 text-[var(--color-error)] rotate-180" />;
|
||||
default:
|
||||
return <div className="h-4 w-4 bg-gray-400 rounded-full" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskBadge = (risk: string) => {
|
||||
const riskConfig = {
|
||||
low: { color: 'green', text: 'Bajo' },
|
||||
medium: { color: 'yellow', text: 'Medio' },
|
||||
high: { color: 'red', text: 'Alto' },
|
||||
};
|
||||
|
||||
const config = riskConfig[risk as keyof typeof riskConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Predicción de Demanda"
|
||||
description="Predicciones inteligentes basadas en IA para optimizar tu producción"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configurar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Precisión del Modelo</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{forecastData.accuracy}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Demanda Prevista</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{forecastData.totalDemand}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">próximos {forecastPeriod} días</p>
|
||||
</div>
|
||||
<Calendar className="h-12 w-12 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Tendencia</p>
|
||||
<p className="text-3xl font-bold text-purple-600">+{forecastData.growthTrend}%</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">vs período anterior</p>
|
||||
</div>
|
||||
<TrendingUp className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Factor Estacional</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{forecastData.seasonalityFactor}x</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">multiplicador actual</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Producto</label>
|
||||
<select
|
||||
value={selectedProduct}
|
||||
onChange={(e) => setSelectedProduct(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={forecastPeriod}
|
||||
onChange={(e) => setForecastPeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period.value} value={period.value}>{period.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Vista</label>
|
||||
<div className="flex rounded-md border border-[var(--border-secondary)]">
|
||||
<button
|
||||
onClick={() => setViewMode('chart')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'chart' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-l-md`}
|
||||
>
|
||||
Gráfico
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'table' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-r-md border-l`}
|
||||
>
|
||||
Tabla
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Forecast Display */}
|
||||
<div className="lg:col-span-2">
|
||||
{viewMode === 'chart' ? (
|
||||
<DemandChart
|
||||
product={selectedProduct}
|
||||
period={forecastPeriod}
|
||||
/>
|
||||
) : (
|
||||
<ForecastTable forecasts={mockForecasts} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts Panel */}
|
||||
<div className="space-y-6">
|
||||
<AlertsPanel alerts={alerts} />
|
||||
|
||||
{/* Weather Impact */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto Meteorológico</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Hoy:</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
|
||||
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Factor de demanda:</span>
|
||||
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.demandFactor}x</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-2">Categorías afectadas:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{weatherImpact.affectedCategories.map((category, index) => (
|
||||
<Badge key={index} variant="blue">{category}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Insights */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Patrones Estacionales</h3>
|
||||
<div className="space-y-3">
|
||||
{seasonalInsights.map((insight, index) => (
|
||||
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{insight.period}</span>
|
||||
<span className="text-sm text-purple-600 font-medium">{insight.factor}x</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{insight.products.map((product, idx) => (
|
||||
<Badge key={idx} variant="purple">{product}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Forecasts Table */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-[var(--bg-secondary)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Stock Actual
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Demanda Prevista
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Producción Recomendada
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Confianza
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Tendencia
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Riesgo Agotamiento
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockForecasts.map((forecast) => (
|
||||
<tr key={forecast.id} className="hover:bg-[var(--bg-secondary)]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{forecast.product}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{forecast.currentStock}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-[var(--color-info)]">
|
||||
{forecast.forecastDemand}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-[var(--color-success)]">
|
||||
{forecast.recommendedProduction}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{forecast.confidence}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(forecast.trend)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getRiskBadge(forecast.stockoutRisk)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastingPage;
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings } from 'lucide-react';
|
||||
import { Button, Card, Badge, Select } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting';
|
||||
|
||||
const ForecastingPage: React.FC = () => {
|
||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||
const [forecastPeriod, setForecastPeriod] = useState('7');
|
||||
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
||||
|
||||
const forecastData = {
|
||||
accuracy: 92,
|
||||
totalDemand: 1247,
|
||||
growthTrend: 8.5,
|
||||
seasonalityFactor: 1.15,
|
||||
};
|
||||
|
||||
const products = [
|
||||
{ id: 'all', name: 'Todos los productos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
];
|
||||
|
||||
const periods = [
|
||||
{ value: '7', label: '7 días' },
|
||||
{ value: '14', label: '14 días' },
|
||||
{ value: '30', label: '30 días' },
|
||||
{ value: '90', label: '3 meses' },
|
||||
];
|
||||
|
||||
const mockForecasts = [
|
||||
{
|
||||
id: '1',
|
||||
product: 'Pan de Molde Integral',
|
||||
currentStock: 25,
|
||||
forecastDemand: 45,
|
||||
recommendedProduction: 50,
|
||||
confidence: 95,
|
||||
trend: 'up',
|
||||
stockoutRisk: 'low',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
product: 'Croissants de Mantequilla',
|
||||
currentStock: 18,
|
||||
forecastDemand: 32,
|
||||
recommendedProduction: 35,
|
||||
confidence: 88,
|
||||
trend: 'stable',
|
||||
stockoutRisk: 'medium',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
product: 'Baguettes Francesas',
|
||||
currentStock: 12,
|
||||
forecastDemand: 28,
|
||||
recommendedProduction: 30,
|
||||
confidence: 91,
|
||||
trend: 'down',
|
||||
stockoutRisk: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
const alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'stockout',
|
||||
product: 'Baguettes Francesas',
|
||||
message: 'Alto riesgo de agotamiento en las próximas 24h',
|
||||
severity: 'high',
|
||||
recommendation: 'Incrementar producción en 15 unidades',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'overstock',
|
||||
product: 'Magdalenas',
|
||||
message: 'Probable exceso de stock para mañana',
|
||||
severity: 'medium',
|
||||
recommendation: 'Reducir producción en 20%',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'weather',
|
||||
product: 'Todos',
|
||||
message: 'Lluvia prevista - incremento esperado en demanda de bollería',
|
||||
severity: 'info',
|
||||
recommendation: 'Aumentar producción de productos de interior en 10%',
|
||||
},
|
||||
];
|
||||
|
||||
const weatherImpact = {
|
||||
today: 'sunny',
|
||||
temperature: 22,
|
||||
demandFactor: 0.95,
|
||||
affectedCategories: ['helados', 'bebidas frías'],
|
||||
};
|
||||
|
||||
const seasonalInsights = [
|
||||
{ period: 'Mañana', factor: 1.2, products: ['Pan', 'Bollería'] },
|
||||
{ period: 'Tarde', factor: 0.8, products: ['Tartas', 'Dulces'] },
|
||||
{ period: 'Fin de semana', factor: 1.4, products: ['Tartas especiales'] },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4 text-green-600" />;
|
||||
case 'down':
|
||||
return <TrendingUp className="h-4 w-4 text-red-600 rotate-180" />;
|
||||
default:
|
||||
return <div className="h-4 w-4 bg-gray-400 rounded-full" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskBadge = (risk: string) => {
|
||||
const riskConfig = {
|
||||
low: { color: 'green', text: 'Bajo' },
|
||||
medium: { color: 'yellow', text: 'Medio' },
|
||||
high: { color: 'red', text: 'Alto' },
|
||||
};
|
||||
|
||||
const config = riskConfig[risk as keyof typeof riskConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Predicción de Demanda"
|
||||
description="Predicciones inteligentes basadas en IA para optimizar tu producción"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configurar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Precisión del Modelo</p>
|
||||
<p className="text-3xl font-bold text-green-600">{forecastData.accuracy}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Demanda Prevista</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{forecastData.totalDemand}</p>
|
||||
<p className="text-xs text-gray-500">próximos {forecastPeriod} días</p>
|
||||
</div>
|
||||
<Calendar className="h-12 w-12 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Tendencia</p>
|
||||
<p className="text-3xl font-bold text-purple-600">+{forecastData.growthTrend}%</p>
|
||||
<p className="text-xs text-gray-500">vs período anterior</p>
|
||||
</div>
|
||||
<TrendingUp className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Factor Estacional</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{forecastData.seasonalityFactor}x</p>
|
||||
<p className="text-xs text-gray-500">multiplicador actual</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Producto</label>
|
||||
<select
|
||||
value={selectedProduct}
|
||||
onChange={(e) => setSelectedProduct(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={forecastPeriod}
|
||||
onChange={(e) => setForecastPeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period.value} value={period.value}>{period.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Vista</label>
|
||||
<div className="flex rounded-md border border-gray-300">
|
||||
<button
|
||||
onClick={() => setViewMode('chart')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'chart' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-l-md`}
|
||||
>
|
||||
Gráfico
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'table' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-r-md border-l`}
|
||||
>
|
||||
Tabla
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Forecast Display */}
|
||||
<div className="lg:col-span-2">
|
||||
{viewMode === 'chart' ? (
|
||||
<DemandChart
|
||||
product={selectedProduct}
|
||||
period={forecastPeriod}
|
||||
/>
|
||||
) : (
|
||||
<ForecastTable forecasts={mockForecasts} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts Panel */}
|
||||
<div className="space-y-6">
|
||||
<AlertsPanel alerts={alerts} />
|
||||
|
||||
{/* Weather Impact */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Impacto Meteorológico</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Hoy:</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
|
||||
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Factor de demanda:</span>
|
||||
<span className="text-sm font-medium text-blue-600">{weatherImpact.demandFactor}x</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-gray-500 mb-2">Categorías afectadas:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{weatherImpact.affectedCategories.map((category, index) => (
|
||||
<Badge key={index} variant="blue">{category}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Insights */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Patrones Estacionales</h3>
|
||||
<div className="space-y-3">
|
||||
{seasonalInsights.map((insight, index) => (
|
||||
<div key={index} className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{insight.period}</span>
|
||||
<span className="text-sm text-purple-600 font-medium">{insight.factor}x</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{insight.products.map((product, idx) => (
|
||||
<Badge key={idx} variant="purple">{product}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Forecasts Table */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Predicciones Detalladas</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stock Actual
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Demanda Prevista
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Producción Recomendada
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Confianza
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tendencia
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Riesgo Agotamiento
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockForecasts.map((forecast) => (
|
||||
<tr key={forecast.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{forecast.product}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{forecast.currentStock}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-blue-600">
|
||||
{forecast.forecastDemand}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-green-600">
|
||||
{forecast.recommendedProduction}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{forecast.confidence}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(forecast.trend)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getRiskBadge(forecast.stockoutRisk)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastingPage;
|
||||
1
frontend/src/pages/app/analytics/forecasting/index.ts
Normal file
1
frontend/src/pages/app/analytics/forecasting/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ForecastingPage } from './ForecastingPage';
|
||||
2
frontend/src/pages/app/analytics/index.ts
Normal file
2
frontend/src/pages/app/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './forecasting';
|
||||
export * from './sales-analytics';
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Activity, Clock, Users, TrendingUp, Target, AlertCircle, Download, Calendar } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PerformanceAnalyticsPage: React.FC = () => {
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('efficiency');
|
||||
|
||||
const performanceMetrics = {
|
||||
overallEfficiency: 87.5,
|
||||
productionTime: 4.2,
|
||||
qualityScore: 92.1,
|
||||
employeeProductivity: 89.3,
|
||||
customerSatisfaction: 94.7,
|
||||
resourceUtilization: 78.9,
|
||||
};
|
||||
|
||||
const timeframes = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
{ value: 'quarter', label: 'Trimestre' },
|
||||
{ value: 'year', label: 'Año' },
|
||||
];
|
||||
|
||||
const departmentPerformance = [
|
||||
{
|
||||
department: 'Producción',
|
||||
efficiency: 91.2,
|
||||
trend: 5.3,
|
||||
issues: 2,
|
||||
employees: 8,
|
||||
metrics: {
|
||||
avgBatchTime: '2.3h',
|
||||
qualityRate: '94%',
|
||||
wastePercentage: '3.1%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Ventas',
|
||||
efficiency: 88.7,
|
||||
trend: -1.2,
|
||||
issues: 1,
|
||||
employees: 4,
|
||||
metrics: {
|
||||
avgServiceTime: '3.2min',
|
||||
customerWaitTime: '2.1min',
|
||||
salesPerHour: '€127'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Inventario',
|
||||
efficiency: 82.4,
|
||||
trend: 2.8,
|
||||
issues: 3,
|
||||
employees: 2,
|
||||
metrics: {
|
||||
stockAccuracy: '96.7%',
|
||||
turnoverRate: '12.3',
|
||||
wastageRate: '4.2%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Administración',
|
||||
efficiency: 94.1,
|
||||
trend: 8.1,
|
||||
issues: 0,
|
||||
employees: 3,
|
||||
metrics: {
|
||||
responseTime: '1.2h',
|
||||
taskCompletion: '98%',
|
||||
documentAccuracy: '99.1%'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const kpiTrends = [
|
||||
{
|
||||
name: 'Eficiencia General',
|
||||
current: 87.5,
|
||||
target: 90.0,
|
||||
previous: 84.2,
|
||||
unit: '%',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Tiempo de Producción',
|
||||
current: 4.2,
|
||||
target: 4.0,
|
||||
previous: 4.5,
|
||||
unit: 'h',
|
||||
color: 'green',
|
||||
inverse: true
|
||||
},
|
||||
{
|
||||
name: 'Satisfacción Cliente',
|
||||
current: 94.7,
|
||||
target: 95.0,
|
||||
previous: 93.1,
|
||||
unit: '%',
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: 'Utilización de Recursos',
|
||||
current: 78.9,
|
||||
target: 85.0,
|
||||
previous: 76.3,
|
||||
unit: '%',
|
||||
color: 'orange'
|
||||
}
|
||||
];
|
||||
|
||||
const performanceAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
title: 'Eficiencia de Inventario Baja',
|
||||
description: 'El departamento de inventario está por debajo del objetivo del 85%',
|
||||
value: '82.4%',
|
||||
target: '85%',
|
||||
department: 'Inventario'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
title: 'Tiempo de Producción Mejorado',
|
||||
description: 'El tiempo promedio de producción ha mejorado este mes',
|
||||
value: '4.2h',
|
||||
target: '4.0h',
|
||||
department: 'Producción'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
title: 'Administración Supera Objetivos',
|
||||
description: 'El departamento administrativo está funcionando por encima del objetivo',
|
||||
value: '94.1%',
|
||||
target: '90%',
|
||||
department: 'Administración'
|
||||
}
|
||||
];
|
||||
|
||||
const productivityData = [
|
||||
{ hour: '07:00', efficiency: 75, transactions: 12, employees: 3 },
|
||||
{ hour: '08:00', efficiency: 82, transactions: 18, employees: 5 },
|
||||
{ hour: '09:00', efficiency: 89, transactions: 28, employees: 6 },
|
||||
{ hour: '10:00', efficiency: 91, transactions: 32, employees: 7 },
|
||||
{ hour: '11:00', efficiency: 94, transactions: 38, employees: 8 },
|
||||
{ hour: '12:00', efficiency: 96, transactions: 45, employees: 8 },
|
||||
{ hour: '13:00', efficiency: 95, transactions: 42, employees: 8 },
|
||||
{ hour: '14:00', efficiency: 88, transactions: 35, employees: 7 },
|
||||
{ hour: '15:00', efficiency: 85, transactions: 28, employees: 6 },
|
||||
{ hour: '16:00', efficiency: 83, transactions: 25, employees: 5 },
|
||||
{ hour: '17:00', efficiency: 87, transactions: 31, employees: 6 },
|
||||
{ hour: '18:00', efficiency: 90, transactions: 38, employees: 7 },
|
||||
{ hour: '19:00', efficiency: 86, transactions: 29, employees: 5 },
|
||||
{ hour: '20:00', efficiency: 78, transactions: 18, employees: 3 },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
if (trend > 0) {
|
||||
return <TrendingUp className="w-4 h-4 text-[var(--color-success)]" />;
|
||||
} else {
|
||||
return <TrendingUp className="w-4 h-4 text-[var(--color-error)] transform rotate-180" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
const getPerformanceColor = (value: number, target: number, inverse = false) => {
|
||||
const comparison = inverse ? value < target : value >= target;
|
||||
return comparison ? 'text-[var(--color-success)]' : value >= target * 0.9 ? 'text-yellow-600' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-600" />;
|
||||
case 'success':
|
||||
return <TrendingUp className="w-5 h-5 text-[var(--color-success)]" />;
|
||||
default:
|
||||
return <Activity className="w-5 h-5 text-[var(--color-info)]" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 border-yellow-200';
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200';
|
||||
default:
|
||||
return 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea la eficiencia operativa y el rendimiento de todos los departamentos"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Configurar Alertas
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={selectedTimeframe}
|
||||
onChange={(e) => setSelectedTimeframe(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{timeframes.map(timeframe => (
|
||||
<option key={timeframe.value} value={timeframe.value}>{timeframe.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="productivity">Productividad</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="satisfaction">Satisfacción</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* KPI Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpiTrends.map((kpi) => (
|
||||
<Card key={kpi.name} className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{kpi.name}</h3>
|
||||
<div className={`w-3 h-3 rounded-full bg-${kpi.color}-500`}></div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${getPerformanceColor(kpi.current, kpi.target, kpi.inverse)}`}>
|
||||
{kpi.current}{kpi.unit}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Objetivo: {kpi.target}{kpi.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(kpi.current - kpi.previous)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(kpi.current - kpi.previous)}`}>
|
||||
{Math.abs(kpi.current - kpi.previous).toFixed(1)}{kpi.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className={`bg-${kpi.color}-500 h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min((kpi.current / kpi.target) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Performance Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas de Rendimiento</h3>
|
||||
<div className="space-y-3">
|
||||
{performanceAlerts.map((alert) => (
|
||||
<div key={alert.id} className={`p-4 rounded-lg border ${getAlertColor(alert.type)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">{alert.title}</h4>
|
||||
<Badge variant="gray">{alert.department}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{alert.description}</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm">
|
||||
<strong>Actual:</strong> {alert.value}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<strong>Objetivo:</strong> {alert.target}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Department Performance */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Rendimiento por Departamento</h3>
|
||||
<div className="space-y-4">
|
||||
{departmentPerformance.map((dept) => (
|
||||
<div key={dept.department} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{dept.department}</h4>
|
||||
<Badge variant="gray">{dept.employees} empleados</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{dept.efficiency}%
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(dept.trend)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(dept.trend)}`}>
|
||||
{Math.abs(dept.trend).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
{Object.entries(dept.metrics).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-[var(--text-tertiary)] text-xs">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="font-medium">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dept.issues > 0 && (
|
||||
<div className="mt-3 flex items-center text-sm text-[var(--color-warning)]">
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
{dept.issues} problema{dept.issues > 1 ? 's' : ''} detectado{dept.issues > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Hourly Productivity */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Eficiencia por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{productivityData.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.efficiency}%</div>
|
||||
<div
|
||||
className="w-full bg-[var(--color-info)]/50 rounded-t"
|
||||
style={{
|
||||
height: `${(data.efficiency / 100) * 200}px`,
|
||||
minHeight: '8px',
|
||||
backgroundColor: data.efficiency >= 90 ? '#10B981' : data.efficiency >= 80 ? '#F59E0B' : '#EF4444'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center space-x-6 text-xs">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded mr-1"></div>
|
||||
<span>≥90% Excelente</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded mr-1"></div>
|
||||
<span>80-89% Bueno</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-red-500 rounded mr-1"></div>
|
||||
<span><80% Bajo</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceAnalyticsPage;
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Activity, Clock, Users, TrendingUp, Target, AlertCircle, Download, Calendar } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PerformanceAnalyticsPage: React.FC = () => {
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('efficiency');
|
||||
|
||||
const performanceMetrics = {
|
||||
overallEfficiency: 87.5,
|
||||
productionTime: 4.2,
|
||||
qualityScore: 92.1,
|
||||
employeeProductivity: 89.3,
|
||||
customerSatisfaction: 94.7,
|
||||
resourceUtilization: 78.9,
|
||||
};
|
||||
|
||||
const timeframes = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
{ value: 'quarter', label: 'Trimestre' },
|
||||
{ value: 'year', label: 'Año' },
|
||||
];
|
||||
|
||||
const departmentPerformance = [
|
||||
{
|
||||
department: 'Producción',
|
||||
efficiency: 91.2,
|
||||
trend: 5.3,
|
||||
issues: 2,
|
||||
employees: 8,
|
||||
metrics: {
|
||||
avgBatchTime: '2.3h',
|
||||
qualityRate: '94%',
|
||||
wastePercentage: '3.1%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Ventas',
|
||||
efficiency: 88.7,
|
||||
trend: -1.2,
|
||||
issues: 1,
|
||||
employees: 4,
|
||||
metrics: {
|
||||
avgServiceTime: '3.2min',
|
||||
customerWaitTime: '2.1min',
|
||||
salesPerHour: '€127'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Inventario',
|
||||
efficiency: 82.4,
|
||||
trend: 2.8,
|
||||
issues: 3,
|
||||
employees: 2,
|
||||
metrics: {
|
||||
stockAccuracy: '96.7%',
|
||||
turnoverRate: '12.3',
|
||||
wastageRate: '4.2%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Administración',
|
||||
efficiency: 94.1,
|
||||
trend: 8.1,
|
||||
issues: 0,
|
||||
employees: 3,
|
||||
metrics: {
|
||||
responseTime: '1.2h',
|
||||
taskCompletion: '98%',
|
||||
documentAccuracy: '99.1%'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const kpiTrends = [
|
||||
{
|
||||
name: 'Eficiencia General',
|
||||
current: 87.5,
|
||||
target: 90.0,
|
||||
previous: 84.2,
|
||||
unit: '%',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Tiempo de Producción',
|
||||
current: 4.2,
|
||||
target: 4.0,
|
||||
previous: 4.5,
|
||||
unit: 'h',
|
||||
color: 'green',
|
||||
inverse: true
|
||||
},
|
||||
{
|
||||
name: 'Satisfacción Cliente',
|
||||
current: 94.7,
|
||||
target: 95.0,
|
||||
previous: 93.1,
|
||||
unit: '%',
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: 'Utilización de Recursos',
|
||||
current: 78.9,
|
||||
target: 85.0,
|
||||
previous: 76.3,
|
||||
unit: '%',
|
||||
color: 'orange'
|
||||
}
|
||||
];
|
||||
|
||||
const performanceAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
title: 'Eficiencia de Inventario Baja',
|
||||
description: 'El departamento de inventario está por debajo del objetivo del 85%',
|
||||
value: '82.4%',
|
||||
target: '85%',
|
||||
department: 'Inventario'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
title: 'Tiempo de Producción Mejorado',
|
||||
description: 'El tiempo promedio de producción ha mejorado este mes',
|
||||
value: '4.2h',
|
||||
target: '4.0h',
|
||||
department: 'Producción'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
title: 'Administración Supera Objetivos',
|
||||
description: 'El departamento administrativo está funcionando por encima del objetivo',
|
||||
value: '94.1%',
|
||||
target: '90%',
|
||||
department: 'Administración'
|
||||
}
|
||||
];
|
||||
|
||||
const productivityData = [
|
||||
{ hour: '07:00', efficiency: 75, transactions: 12, employees: 3 },
|
||||
{ hour: '08:00', efficiency: 82, transactions: 18, employees: 5 },
|
||||
{ hour: '09:00', efficiency: 89, transactions: 28, employees: 6 },
|
||||
{ hour: '10:00', efficiency: 91, transactions: 32, employees: 7 },
|
||||
{ hour: '11:00', efficiency: 94, transactions: 38, employees: 8 },
|
||||
{ hour: '12:00', efficiency: 96, transactions: 45, employees: 8 },
|
||||
{ hour: '13:00', efficiency: 95, transactions: 42, employees: 8 },
|
||||
{ hour: '14:00', efficiency: 88, transactions: 35, employees: 7 },
|
||||
{ hour: '15:00', efficiency: 85, transactions: 28, employees: 6 },
|
||||
{ hour: '16:00', efficiency: 83, transactions: 25, employees: 5 },
|
||||
{ hour: '17:00', efficiency: 87, transactions: 31, employees: 6 },
|
||||
{ hour: '18:00', efficiency: 90, transactions: 38, employees: 7 },
|
||||
{ hour: '19:00', efficiency: 86, transactions: 29, employees: 5 },
|
||||
{ hour: '20:00', efficiency: 78, transactions: 18, employees: 3 },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
if (trend > 0) {
|
||||
return <TrendingUp className="w-4 h-4 text-green-600" />;
|
||||
} else {
|
||||
return <TrendingUp className="w-4 h-4 text-red-600 transform rotate-180" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
const getPerformanceColor = (value: number, target: number, inverse = false) => {
|
||||
const comparison = inverse ? value < target : value >= target;
|
||||
return comparison ? 'text-green-600' : value >= target * 0.9 ? 'text-yellow-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-600" />;
|
||||
case 'success':
|
||||
return <TrendingUp className="w-5 h-5 text-green-600" />;
|
||||
default:
|
||||
return <Activity className="w-5 h-5 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 border-yellow-200';
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200';
|
||||
default:
|
||||
return 'bg-blue-50 border-blue-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea la eficiencia operativa y el rendimiento de todos los departamentos"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Configurar Alertas
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedTimeframe}
|
||||
onChange={(e) => setSelectedTimeframe(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{timeframes.map(timeframe => (
|
||||
<option key={timeframe.value} value={timeframe.value}>{timeframe.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="productivity">Productividad</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="satisfaction">Satisfacción</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* KPI Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpiTrends.map((kpi) => (
|
||||
<Card key={kpi.name} className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">{kpi.name}</h3>
|
||||
<div className={`w-3 h-3 rounded-full bg-${kpi.color}-500`}></div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${getPerformanceColor(kpi.current, kpi.target, kpi.inverse)}`}>
|
||||
{kpi.current}{kpi.unit}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Objetivo: {kpi.target}{kpi.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(kpi.current - kpi.previous)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(kpi.current - kpi.previous)}`}>
|
||||
{Math.abs(kpi.current - kpi.previous).toFixed(1)}{kpi.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`bg-${kpi.color}-500 h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min((kpi.current / kpi.target) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Performance Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alertas de Rendimiento</h3>
|
||||
<div className="space-y-3">
|
||||
{performanceAlerts.map((alert) => (
|
||||
<div key={alert.id} className={`p-4 rounded-lg border ${getAlertColor(alert.type)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">{alert.title}</h4>
|
||||
<Badge variant="gray">{alert.department}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{alert.description}</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm">
|
||||
<strong>Actual:</strong> {alert.value}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<strong>Objetivo:</strong> {alert.target}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Department Performance */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Rendimiento por Departamento</h3>
|
||||
<div className="space-y-4">
|
||||
{departmentPerformance.map((dept) => (
|
||||
<div key={dept.department} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="font-medium text-gray-900">{dept.department}</h4>
|
||||
<Badge variant="gray">{dept.employees} empleados</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{dept.efficiency}%
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(dept.trend)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(dept.trend)}`}>
|
||||
{Math.abs(dept.trend).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
{Object.entries(dept.metrics).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="font-medium">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dept.issues > 0 && (
|
||||
<div className="mt-3 flex items-center text-sm text-amber-600">
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
{dept.issues} problema{dept.issues > 1 ? 's' : ''} detectado{dept.issues > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Hourly Productivity */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Eficiencia por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{productivityData.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">{data.efficiency}%</div>
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.efficiency / 100) * 200}px`,
|
||||
minHeight: '8px',
|
||||
backgroundColor: data.efficiency >= 90 ? '#10B981' : data.efficiency >= 80 ? '#F59E0B' : '#EF4444'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center space-x-6 text-xs">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded mr-1"></div>
|
||||
<span>≥90% Excelente</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded mr-1"></div>
|
||||
<span>80-89% Bueno</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-red-500 rounded mr-1"></div>
|
||||
<span><80% Bajo</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceAnalyticsPage;
|
||||
1
frontend/src/pages/app/analytics/performance/index.ts
Normal file
1
frontend/src/pages/app/analytics/performance/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as PerformanceAnalyticsPage } from './PerformanceAnalyticsPage';
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { AnalyticsDashboard, ChartWidget, ReportsTable } from '../../../../components/domain/analytics';
|
||||
|
||||
const SalesAnalyticsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('revenue');
|
||||
const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview');
|
||||
|
||||
const salesMetrics = {
|
||||
totalRevenue: 45678.90,
|
||||
totalOrders: 1234,
|
||||
averageOrderValue: 37.02,
|
||||
customerCount: 856,
|
||||
growthRate: 12.5,
|
||||
conversionRate: 68.4,
|
||||
};
|
||||
|
||||
const periods = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
{ value: 'quarter', label: 'Este Trimestre' },
|
||||
{ value: 'year', label: 'Este Año' },
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
{ value: 'revenue', label: 'Ingresos' },
|
||||
{ value: 'orders', label: 'Pedidos' },
|
||||
{ value: 'customers', label: 'Clientes' },
|
||||
{ value: 'products', label: 'Productos' },
|
||||
];
|
||||
|
||||
const topProducts = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
revenue: 2250.50,
|
||||
units: 245,
|
||||
growth: 8.2,
|
||||
category: 'Panes'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
revenue: 1890.75,
|
||||
units: 412,
|
||||
growth: 15.4,
|
||||
category: 'Bollería'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Chocolate',
|
||||
revenue: 1675.00,
|
||||
units: 67,
|
||||
growth: -2.1,
|
||||
category: 'Tartas'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Empanadas Variadas',
|
||||
revenue: 1425.25,
|
||||
units: 285,
|
||||
growth: 22.8,
|
||||
category: 'Salados'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
revenue: 1180.50,
|
||||
units: 394,
|
||||
growth: 5.7,
|
||||
category: 'Bollería'
|
||||
},
|
||||
];
|
||||
|
||||
const salesByHour = [
|
||||
{ hour: '07:00', sales: 145, orders: 12 },
|
||||
{ hour: '08:00', sales: 289, orders: 18 },
|
||||
{ hour: '09:00', sales: 425, orders: 28 },
|
||||
{ hour: '10:00', sales: 380, orders: 24 },
|
||||
{ hour: '11:00', sales: 520, orders: 31 },
|
||||
{ hour: '12:00', sales: 675, orders: 42 },
|
||||
{ hour: '13:00', sales: 720, orders: 45 },
|
||||
{ hour: '14:00', sales: 580, orders: 35 },
|
||||
{ hour: '15:00', sales: 420, orders: 28 },
|
||||
{ hour: '16:00', sales: 350, orders: 22 },
|
||||
{ hour: '17:00', sales: 480, orders: 31 },
|
||||
{ hour: '18:00', sales: 620, orders: 38 },
|
||||
{ hour: '19:00', sales: 450, orders: 29 },
|
||||
{ hour: '20:00', sales: 280, orders: 18 },
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{ segment: 'Clientes Frecuentes', count: 123, revenue: 15678, percentage: 34.3 },
|
||||
{ segment: 'Clientes Regulares', count: 245, revenue: 18950, percentage: 41.5 },
|
||||
{ segment: 'Clientes Ocasionales', count: 356, revenue: 8760, percentage: 19.2 },
|
||||
{ segment: 'Clientes Nuevos', count: 132, revenue: 2290, percentage: 5.0 },
|
||||
];
|
||||
|
||||
const paymentMethods = [
|
||||
{ method: 'Tarjeta', count: 567, revenue: 28450, percentage: 62.3 },
|
||||
{ method: 'Efectivo', count: 445, revenue: 13890, percentage: 30.4 },
|
||||
{ method: 'Transferencia', count: 178, revenue: 2890, percentage: 6.3 },
|
||||
{ method: 'Otros', count: 44, revenue: 448, percentage: 1.0 },
|
||||
];
|
||||
|
||||
const getGrowthBadge = (growth: number) => {
|
||||
if (growth > 0) {
|
||||
return <Badge variant="green">+{growth.toFixed(1)}%</Badge>;
|
||||
} else if (growth < 0) {
|
||||
return <Badge variant="red">{growth.toFixed(1)}%</Badge>;
|
||||
} else {
|
||||
return <Badge variant="gray">0%</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getGrowthColor = (growth: number) => {
|
||||
return growth >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Ventas"
|
||||
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period.value} value={period.value}>{period.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{metrics.map(metric => (
|
||||
<option key={metric.value} value={metric.value}>{metric.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Vista</label>
|
||||
<div className="flex rounded-md border border-[var(--border-secondary)]">
|
||||
<button
|
||||
onClick={() => setViewMode('overview')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'overview' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-l-md`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'detailed' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-r-md border-l`}
|
||||
>
|
||||
Detallado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ingresos Totales</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">€{salesMetrics.totalRevenue.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-[var(--color-success)]" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{getGrowthBadge(salesMetrics.growthRate)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{salesMetrics.totalOrders.toLocaleString()}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-8 w-8 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Valor Promedio</p>
|
||||
<p className="text-2xl font-bold text-purple-600">€{salesMetrics.averageOrderValue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Clientes</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{salesMetrics.customerCount}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Tasa Crecimiento</p>
|
||||
<p className={`text-2xl font-bold ${getGrowthColor(salesMetrics.growthRate)}`}>
|
||||
{salesMetrics.growthRate > 0 ? '+' : ''}{salesMetrics.growthRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className={`h-8 w-8 ${getGrowthColor(salesMetrics.growthRate)}`} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Conversión</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{salesMetrics.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{viewMode === 'overview' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sales by Hour Chart */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Ventas por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{salesByHour.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className="w-full bg-[var(--color-info)]/50 rounded-t"
|
||||
style={{
|
||||
height: `${(data.sales / Math.max(...salesByHour.map(d => d.sales))) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Top Products */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Productos Más Vendidos</h3>
|
||||
<div className="space-y-3">
|
||||
{topProducts.slice(0, 5).map((product, index) => (
|
||||
<div key={product.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-[var(--text-tertiary)] w-6">{index + 1}.</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{product.name}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{product.category} • {product.units} unidades</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">€{product.revenue.toLocaleString()}</p>
|
||||
{getGrowthBadge(product.growth)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{segment.segment}</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">{segment.percentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${segment.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-[var(--text-tertiary)]">
|
||||
<span>{segment.count} clientes</span>
|
||||
<span>€{segment.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Métodos de Pago</h3>
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-[var(--color-info)]/50 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{method.method}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{method.count} transacciones</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">€{method.revenue.toLocaleString()}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{method.percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Detailed Analytics Dashboard */}
|
||||
<AnalyticsDashboard />
|
||||
|
||||
{/* Detailed Reports Table */}
|
||||
<ReportsTable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsPage;
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { AnalyticsDashboard, ChartWidget, ReportsTable } from '../../../../components/domain/analytics';
|
||||
|
||||
const SalesAnalyticsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('revenue');
|
||||
const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview');
|
||||
|
||||
const salesMetrics = {
|
||||
totalRevenue: 45678.90,
|
||||
totalOrders: 1234,
|
||||
averageOrderValue: 37.02,
|
||||
customerCount: 856,
|
||||
growthRate: 12.5,
|
||||
conversionRate: 68.4,
|
||||
};
|
||||
|
||||
const periods = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
{ value: 'quarter', label: 'Este Trimestre' },
|
||||
{ value: 'year', label: 'Este Año' },
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
{ value: 'revenue', label: 'Ingresos' },
|
||||
{ value: 'orders', label: 'Pedidos' },
|
||||
{ value: 'customers', label: 'Clientes' },
|
||||
{ value: 'products', label: 'Productos' },
|
||||
];
|
||||
|
||||
const topProducts = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
revenue: 2250.50,
|
||||
units: 245,
|
||||
growth: 8.2,
|
||||
category: 'Panes'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
revenue: 1890.75,
|
||||
units: 412,
|
||||
growth: 15.4,
|
||||
category: 'Bollería'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Chocolate',
|
||||
revenue: 1675.00,
|
||||
units: 67,
|
||||
growth: -2.1,
|
||||
category: 'Tartas'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Empanadas Variadas',
|
||||
revenue: 1425.25,
|
||||
units: 285,
|
||||
growth: 22.8,
|
||||
category: 'Salados'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
revenue: 1180.50,
|
||||
units: 394,
|
||||
growth: 5.7,
|
||||
category: 'Bollería'
|
||||
},
|
||||
];
|
||||
|
||||
const salesByHour = [
|
||||
{ hour: '07:00', sales: 145, orders: 12 },
|
||||
{ hour: '08:00', sales: 289, orders: 18 },
|
||||
{ hour: '09:00', sales: 425, orders: 28 },
|
||||
{ hour: '10:00', sales: 380, orders: 24 },
|
||||
{ hour: '11:00', sales: 520, orders: 31 },
|
||||
{ hour: '12:00', sales: 675, orders: 42 },
|
||||
{ hour: '13:00', sales: 720, orders: 45 },
|
||||
{ hour: '14:00', sales: 580, orders: 35 },
|
||||
{ hour: '15:00', sales: 420, orders: 28 },
|
||||
{ hour: '16:00', sales: 350, orders: 22 },
|
||||
{ hour: '17:00', sales: 480, orders: 31 },
|
||||
{ hour: '18:00', sales: 620, orders: 38 },
|
||||
{ hour: '19:00', sales: 450, orders: 29 },
|
||||
{ hour: '20:00', sales: 280, orders: 18 },
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{ segment: 'Clientes Frecuentes', count: 123, revenue: 15678, percentage: 34.3 },
|
||||
{ segment: 'Clientes Regulares', count: 245, revenue: 18950, percentage: 41.5 },
|
||||
{ segment: 'Clientes Ocasionales', count: 356, revenue: 8760, percentage: 19.2 },
|
||||
{ segment: 'Clientes Nuevos', count: 132, revenue: 2290, percentage: 5.0 },
|
||||
];
|
||||
|
||||
const paymentMethods = [
|
||||
{ method: 'Tarjeta', count: 567, revenue: 28450, percentage: 62.3 },
|
||||
{ method: 'Efectivo', count: 445, revenue: 13890, percentage: 30.4 },
|
||||
{ method: 'Transferencia', count: 178, revenue: 2890, percentage: 6.3 },
|
||||
{ method: 'Otros', count: 44, revenue: 448, percentage: 1.0 },
|
||||
];
|
||||
|
||||
const getGrowthBadge = (growth: number) => {
|
||||
if (growth > 0) {
|
||||
return <Badge variant="green">+{growth.toFixed(1)}%</Badge>;
|
||||
} else if (growth < 0) {
|
||||
return <Badge variant="red">{growth.toFixed(1)}%</Badge>;
|
||||
} else {
|
||||
return <Badge variant="gray">0%</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getGrowthColor = (growth: number) => {
|
||||
return growth >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Ventas"
|
||||
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period.value} value={period.value}>{period.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{metrics.map(metric => (
|
||||
<option key={metric.value} value={metric.value}>{metric.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Vista</label>
|
||||
<div className="flex rounded-md border border-gray-300">
|
||||
<button
|
||||
onClick={() => setViewMode('overview')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'overview' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-l-md`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'detailed' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-r-md border-l`}
|
||||
>
|
||||
Detallado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-2xl font-bold text-green-600">€{salesMetrics.totalRevenue.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{getGrowthBadge(salesMetrics.growthRate)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{salesMetrics.totalOrders.toLocaleString()}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Valor Promedio</p>
|
||||
<p className="text-2xl font-bold text-purple-600">€{salesMetrics.averageOrderValue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Clientes</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{salesMetrics.customerCount}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Tasa Crecimiento</p>
|
||||
<p className={`text-2xl font-bold ${getGrowthColor(salesMetrics.growthRate)}`}>
|
||||
{salesMetrics.growthRate > 0 ? '+' : ''}{salesMetrics.growthRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className={`h-8 w-8 ${getGrowthColor(salesMetrics.growthRate)}`} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Conversión</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{salesMetrics.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{viewMode === 'overview' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sales by Hour Chart */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Ventas por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{salesByHour.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.sales / Math.max(...salesByHour.map(d => d.sales))) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Top Products */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Productos Más Vendidos</h3>
|
||||
<div className="space-y-3">
|
||||
{topProducts.slice(0, 5).map((product, index) => (
|
||||
<div key={product.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-500 w-6">{index + 1}.</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{product.name}</p>
|
||||
<p className="text-xs text-gray-500">{product.category} • {product.units} unidades</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">€{product.revenue.toLocaleString()}</p>
|
||||
{getGrowthBadge(product.growth)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-900">{segment.segment}</span>
|
||||
<span className="text-sm text-gray-600">{segment.percentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${segment.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{segment.count} clientes</span>
|
||||
<span>€{segment.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Métodos de Pago</h3>
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{method.method}</p>
|
||||
<p className="text-xs text-gray-500">{method.count} transacciones</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">€{method.revenue.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">{method.percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Detailed Analytics Dashboard */}
|
||||
<AnalyticsDashboard />
|
||||
|
||||
{/* Detailed Reports Table */}
|
||||
<ReportsTable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsPage;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SalesAnalyticsPage } from './SalesAnalyticsPage';
|
||||
414
frontend/src/pages/app/communications/alerts/AlertsPage.tsx
Normal file
414
frontend/src/pages/app/communications/alerts/AlertsPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, AlertTriangle, AlertCircle, CheckCircle, Clock, Settings, Filter, Search } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const AlertsPage: React.FC = () => {
|
||||
const [selectedFilter, setSelectedFilter] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
||||
|
||||
const alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'critical',
|
||||
category: 'inventory',
|
||||
title: 'Stock Crítico - Harina de Trigo',
|
||||
message: 'Quedan solo 5kg de harina de trigo. El stock mínimo es de 20kg.',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
read: false,
|
||||
actionRequired: true,
|
||||
priority: 'high',
|
||||
source: 'Sistema de Inventario',
|
||||
details: {
|
||||
currentStock: '5kg',
|
||||
minimumStock: '20kg',
|
||||
supplier: 'Molinos del Sur',
|
||||
estimatedDepletion: '1 día'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'warning',
|
||||
category: 'production',
|
||||
title: 'Retraso en Producción',
|
||||
message: 'El lote de croissants #CR-024 lleva 45 minutos de retraso.',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
read: false,
|
||||
actionRequired: true,
|
||||
priority: 'medium',
|
||||
source: 'Control de Producción',
|
||||
details: {
|
||||
batchId: 'CR-024',
|
||||
expectedTime: '2.5h',
|
||||
actualTime: '3.25h',
|
||||
delayReason: 'Problema con el horno #2'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'info',
|
||||
category: 'sales',
|
||||
title: 'Pico de Ventas Detectado',
|
||||
message: 'Las ventas han aumentado un 35% en la última hora.',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
read: true,
|
||||
actionRequired: false,
|
||||
priority: 'low',
|
||||
source: 'Sistema de Ventas',
|
||||
details: {
|
||||
increase: '35%',
|
||||
period: 'Última hora',
|
||||
topProducts: ['Croissants', 'Pan Integral', 'Empanadas'],
|
||||
expectedRevenue: '€320'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
category: 'quality',
|
||||
title: 'Control de Calidad Completado',
|
||||
message: 'Lote de pan integral #PI-156 aprobado con puntuación de 9.2/10.',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
read: true,
|
||||
actionRequired: false,
|
||||
priority: 'low',
|
||||
source: 'Control de Calidad',
|
||||
details: {
|
||||
batchId: 'PI-156',
|
||||
score: '9.2/10',
|
||||
inspector: 'María González',
|
||||
testsPassed: '15/15'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'critical',
|
||||
category: 'equipment',
|
||||
title: 'Fallo del Horno Principal',
|
||||
message: 'El horno #1 ha presentado una falla en el sistema de temperatura.',
|
||||
timestamp: '2024-01-25 16:20:00',
|
||||
read: false,
|
||||
actionRequired: true,
|
||||
priority: 'high',
|
||||
source: 'Monitoreo de Equipos',
|
||||
details: {
|
||||
equipment: 'Horno #1',
|
||||
error: 'Sistema de temperatura',
|
||||
impact: 'Producción reducida 50%',
|
||||
technician: 'Pendiente'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'warning',
|
||||
category: 'staff',
|
||||
title: 'Ausentismo del Personal',
|
||||
message: '2 empleados del turno matutino no se han presentado.',
|
||||
timestamp: '2024-01-25 07:00:00',
|
||||
read: true,
|
||||
actionRequired: true,
|
||||
priority: 'medium',
|
||||
source: 'Gestión de Personal',
|
||||
details: {
|
||||
absentEmployees: ['Juan Pérez', 'Ana García'],
|
||||
shift: 'Matutino',
|
||||
coverage: '75%',
|
||||
replacement: 'Solicitada'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const alertStats = {
|
||||
total: alerts.length,
|
||||
unread: alerts.filter(a => !a.read).length,
|
||||
critical: alerts.filter(a => a.type === 'critical').length,
|
||||
actionRequired: alerts.filter(a => a.actionRequired).length
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas', count: alerts.length },
|
||||
{ value: 'inventory', label: 'Inventario', count: alerts.filter(a => a.category === 'inventory').length },
|
||||
{ value: 'production', label: 'Producción', count: alerts.filter(a => a.category === 'production').length },
|
||||
{ value: 'sales', label: 'Ventas', count: alerts.filter(a => a.category === 'sales').length },
|
||||
{ value: 'quality', label: 'Calidad', count: alerts.filter(a => a.category === 'quality').length },
|
||||
{ value: 'equipment', label: 'Equipos', count: alerts.filter(a => a.category === 'equipment').length },
|
||||
{ value: 'staff', label: 'Personal', count: alerts.filter(a => a.category === 'staff').length }
|
||||
];
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'critical': return <AlertTriangle {...iconProps} />;
|
||||
case 'warning': return <AlertCircle {...iconProps} />;
|
||||
case 'success': return <CheckCircle {...iconProps} />;
|
||||
default: return <Bell {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'critical': return 'red';
|
||||
case 'warning': return 'yellow';
|
||||
case 'success': return 'green';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'bg-[var(--color-error)]/10 text-[var(--color-error)]';
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'low': return 'bg-[var(--color-success)]/10 text-[var(--color-success)]';
|
||||
default: return 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAlerts = alerts.filter(alert => {
|
||||
const matchesFilter = selectedFilter === 'all' || alert.category === selectedFilter;
|
||||
const matchesSearch = alert.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
alert.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesFilter && matchesSearch;
|
||||
});
|
||||
|
||||
const handleMarkAsRead = (alertId: string) => {
|
||||
// Handle mark as read logic
|
||||
console.log('Marking alert as read:', alertId);
|
||||
};
|
||||
|
||||
const handleDismissAlert = (alertId: string) => {
|
||||
// Handle dismiss alert logic
|
||||
console.log('Dismissing alert:', alertId);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const alertTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - alertTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInMins = Math.floor(diffInMs / (1000 * 60));
|
||||
|
||||
if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${diffInMins}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Alertas y Notificaciones"
|
||||
description="Gestiona y supervisa todas las alertas del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configurar
|
||||
</Button>
|
||||
<Button>
|
||||
Marcar Todas Leídas
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Alert Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Alertas</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{alertStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Sin Leer</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{alertStats.unread}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Críticas</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-error)]">{alertStats.critical}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Acción Requerida</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{alertStats.actionRequired}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Settings className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar alertas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedFilter}
|
||||
onChange={(e) => setSelectedFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<option key={category.value} value={category.value}>
|
||||
{category.label} ({category.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="space-y-4">
|
||||
{filteredAlerts.map((alert) => (
|
||||
<Card
|
||||
key={alert.id}
|
||||
className={`p-6 cursor-pointer transition-colors ${
|
||||
!alert.read ? 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20' : ''
|
||||
} ${selectedAlert === alert.id ? 'ring-2 ring-blue-500' : ''}`}
|
||||
onClick={() => setSelectedAlert(selectedAlert === alert.id ? null : alert.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className={`p-2 rounded-lg bg-${getAlertColor(alert.type)}-100`}>
|
||||
{getAlertIcon(alert.type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{alert.title}</h3>
|
||||
{!alert.read && (
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
)}
|
||||
<Badge variant={getAlertColor(alert.type)}>
|
||||
{alert.type === 'critical' ? 'Crítica' :
|
||||
alert.type === 'warning' ? 'Advertencia' :
|
||||
alert.type === 'success' ? 'Éxito' : 'Info'}
|
||||
</Badge>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityColor(alert.priority)}`}>
|
||||
Prioridad {alert.priority === 'high' ? 'Alta' : alert.priority === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--text-secondary)] mb-3">{alert.message}</p>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-[var(--text-tertiary)]">
|
||||
<span>{formatTimeAgo(alert.timestamp)}</span>
|
||||
<span>•</span>
|
||||
<span>{alert.source}</span>
|
||||
{alert.actionRequired && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Badge variant="yellow">Acción Requerida</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{!alert.read && (
|
||||
<Button size="sm" variant="outline" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(alert.id);
|
||||
}}>
|
||||
Marcar Leída
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismissAlert(alert.id);
|
||||
}}>
|
||||
Descartar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Details - Expandible */}
|
||||
{selectedAlert === alert.id && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">Detalles de la Alerta</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Object.entries(alert.details).map(([key, value]) => (
|
||||
<div key={key} className="bg-[var(--bg-secondary)] p-3 rounded-lg">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wider mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{alert.actionRequired && (
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<Button size="sm">
|
||||
Tomar Acción
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Escalar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Programar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAlerts.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Bell className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No hay alertas</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
No se encontraron alertas que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsPage;
|
||||
@@ -0,0 +1,414 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, AlertTriangle, AlertCircle, CheckCircle, Clock, Settings, Filter, Search } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const AlertsPage: React.FC = () => {
|
||||
const [selectedFilter, setSelectedFilter] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
||||
|
||||
const alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'critical',
|
||||
category: 'inventory',
|
||||
title: 'Stock Crítico - Harina de Trigo',
|
||||
message: 'Quedan solo 5kg de harina de trigo. El stock mínimo es de 20kg.',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
read: false,
|
||||
actionRequired: true,
|
||||
priority: 'high',
|
||||
source: 'Sistema de Inventario',
|
||||
details: {
|
||||
currentStock: '5kg',
|
||||
minimumStock: '20kg',
|
||||
supplier: 'Molinos del Sur',
|
||||
estimatedDepletion: '1 día'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'warning',
|
||||
category: 'production',
|
||||
title: 'Retraso en Producción',
|
||||
message: 'El lote de croissants #CR-024 lleva 45 minutos de retraso.',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
read: false,
|
||||
actionRequired: true,
|
||||
priority: 'medium',
|
||||
source: 'Control de Producción',
|
||||
details: {
|
||||
batchId: 'CR-024',
|
||||
expectedTime: '2.5h',
|
||||
actualTime: '3.25h',
|
||||
delayReason: 'Problema con el horno #2'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'info',
|
||||
category: 'sales',
|
||||
title: 'Pico de Ventas Detectado',
|
||||
message: 'Las ventas han aumentado un 35% en la última hora.',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
read: true,
|
||||
actionRequired: false,
|
||||
priority: 'low',
|
||||
source: 'Sistema de Ventas',
|
||||
details: {
|
||||
increase: '35%',
|
||||
period: 'Última hora',
|
||||
topProducts: ['Croissants', 'Pan Integral', 'Empanadas'],
|
||||
expectedRevenue: '€320'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
category: 'quality',
|
||||
title: 'Control de Calidad Completado',
|
||||
message: 'Lote de pan integral #PI-156 aprobado con puntuación de 9.2/10.',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
read: true,
|
||||
actionRequired: false,
|
||||
priority: 'low',
|
||||
source: 'Control de Calidad',
|
||||
details: {
|
||||
batchId: 'PI-156',
|
||||
score: '9.2/10',
|
||||
inspector: 'María González',
|
||||
testsPassed: '15/15'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'critical',
|
||||
category: 'equipment',
|
||||
title: 'Fallo del Horno Principal',
|
||||
message: 'El horno #1 ha presentado una falla en el sistema de temperatura.',
|
||||
timestamp: '2024-01-25 16:20:00',
|
||||
read: false,
|
||||
actionRequired: true,
|
||||
priority: 'high',
|
||||
source: 'Monitoreo de Equipos',
|
||||
details: {
|
||||
equipment: 'Horno #1',
|
||||
error: 'Sistema de temperatura',
|
||||
impact: 'Producción reducida 50%',
|
||||
technician: 'Pendiente'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'warning',
|
||||
category: 'staff',
|
||||
title: 'Ausentismo del Personal',
|
||||
message: '2 empleados del turno matutino no se han presentado.',
|
||||
timestamp: '2024-01-25 07:00:00',
|
||||
read: true,
|
||||
actionRequired: true,
|
||||
priority: 'medium',
|
||||
source: 'Gestión de Personal',
|
||||
details: {
|
||||
absentEmployees: ['Juan Pérez', 'Ana García'],
|
||||
shift: 'Matutino',
|
||||
coverage: '75%',
|
||||
replacement: 'Solicitada'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const alertStats = {
|
||||
total: alerts.length,
|
||||
unread: alerts.filter(a => !a.read).length,
|
||||
critical: alerts.filter(a => a.type === 'critical').length,
|
||||
actionRequired: alerts.filter(a => a.actionRequired).length
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas', count: alerts.length },
|
||||
{ value: 'inventory', label: 'Inventario', count: alerts.filter(a => a.category === 'inventory').length },
|
||||
{ value: 'production', label: 'Producción', count: alerts.filter(a => a.category === 'production').length },
|
||||
{ value: 'sales', label: 'Ventas', count: alerts.filter(a => a.category === 'sales').length },
|
||||
{ value: 'quality', label: 'Calidad', count: alerts.filter(a => a.category === 'quality').length },
|
||||
{ value: 'equipment', label: 'Equipos', count: alerts.filter(a => a.category === 'equipment').length },
|
||||
{ value: 'staff', label: 'Personal', count: alerts.filter(a => a.category === 'staff').length }
|
||||
];
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'critical': return <AlertTriangle {...iconProps} />;
|
||||
case 'warning': return <AlertCircle {...iconProps} />;
|
||||
case 'success': return <CheckCircle {...iconProps} />;
|
||||
default: return <Bell {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'critical': return 'red';
|
||||
case 'warning': return 'yellow';
|
||||
case 'success': return 'green';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'bg-red-100 text-red-800';
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'low': return 'bg-green-100 text-green-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAlerts = alerts.filter(alert => {
|
||||
const matchesFilter = selectedFilter === 'all' || alert.category === selectedFilter;
|
||||
const matchesSearch = alert.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
alert.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesFilter && matchesSearch;
|
||||
});
|
||||
|
||||
const handleMarkAsRead = (alertId: string) => {
|
||||
// Handle mark as read logic
|
||||
console.log('Marking alert as read:', alertId);
|
||||
};
|
||||
|
||||
const handleDismissAlert = (alertId: string) => {
|
||||
// Handle dismiss alert logic
|
||||
console.log('Dismissing alert:', alertId);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const alertTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - alertTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInMins = Math.floor(diffInMs / (1000 * 60));
|
||||
|
||||
if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${diffInMins}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Alertas y Notificaciones"
|
||||
description="Gestiona y supervisa todas las alertas del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configurar
|
||||
</Button>
|
||||
<Button>
|
||||
Marcar Todas Leídas
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Alert Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Alertas</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{alertStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Sin Leer</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{alertStats.unread}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Críticas</p>
|
||||
<p className="text-3xl font-bold text-red-600">{alertStats.critical}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Acción Requerida</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{alertStats.actionRequired}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Settings className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar alertas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedFilter}
|
||||
onChange={(e) => setSelectedFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<option key={category.value} value={category.value}>
|
||||
{category.label} ({category.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="space-y-4">
|
||||
{filteredAlerts.map((alert) => (
|
||||
<Card
|
||||
key={alert.id}
|
||||
className={`p-6 cursor-pointer transition-colors ${
|
||||
!alert.read ? 'bg-blue-50 border-blue-200' : ''
|
||||
} ${selectedAlert === alert.id ? 'ring-2 ring-blue-500' : ''}`}
|
||||
onClick={() => setSelectedAlert(selectedAlert === alert.id ? null : alert.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className={`p-2 rounded-lg bg-${getAlertColor(alert.type)}-100`}>
|
||||
{getAlertIcon(alert.type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{alert.title}</h3>
|
||||
{!alert.read && (
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
)}
|
||||
<Badge variant={getAlertColor(alert.type)}>
|
||||
{alert.type === 'critical' ? 'Crítica' :
|
||||
alert.type === 'warning' ? 'Advertencia' :
|
||||
alert.type === 'success' ? 'Éxito' : 'Info'}
|
||||
</Badge>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityColor(alert.priority)}`}>
|
||||
Prioridad {alert.priority === 'high' ? 'Alta' : alert.priority === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-3">{alert.message}</p>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{formatTimeAgo(alert.timestamp)}</span>
|
||||
<span>•</span>
|
||||
<span>{alert.source}</span>
|
||||
{alert.actionRequired && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Badge variant="yellow">Acción Requerida</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{!alert.read && (
|
||||
<Button size="sm" variant="outline" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(alert.id);
|
||||
}}>
|
||||
Marcar Leída
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismissAlert(alert.id);
|
||||
}}>
|
||||
Descartar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Details - Expandible */}
|
||||
{selectedAlert === alert.id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Detalles de la Alerta</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Object.entries(alert.details).map(([key, value]) => (
|
||||
<div key={key} className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{alert.actionRequired && (
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<Button size="sm">
|
||||
Tomar Acción
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Escalar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Programar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAlerts.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay alertas</h3>
|
||||
<p className="text-gray-600">
|
||||
No se encontraron alertas que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsPage;
|
||||
1
frontend/src/pages/app/communications/alerts/index.ts
Normal file
1
frontend/src/pages/app/communications/alerts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AlertsPage } from './AlertsPage';
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, Mail, MessageSquare, Settings, Archive, Trash2, CheckCircle, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const NotificationsPage: React.FC = () => {
|
||||
const [selectedTab, setSelectedTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedNotifications, setSelectedNotifications] = useState<string[]>([]);
|
||||
|
||||
const notifications = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'system',
|
||||
channel: 'app',
|
||||
title: 'Actualización del Sistema',
|
||||
message: 'Nueva versión 2.1.0 disponible con mejoras en el módulo de inventario',
|
||||
timestamp: '2024-01-26 10:15:00',
|
||||
read: false,
|
||||
priority: 'medium',
|
||||
category: 'update',
|
||||
sender: 'Sistema',
|
||||
actions: ['Ver Detalles', 'Instalar Después']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'order',
|
||||
channel: 'email',
|
||||
title: 'Nuevo Pedido Recibido',
|
||||
message: 'Pedido #ORD-456 por €127.50 de Panadería Central',
|
||||
timestamp: '2024-01-26 09:30:00',
|
||||
read: false,
|
||||
priority: 'high',
|
||||
category: 'sales',
|
||||
sender: 'Sistema de Ventas',
|
||||
actions: ['Ver Pedido', 'Procesar']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'inventory',
|
||||
channel: 'sms',
|
||||
title: 'Stock Repuesto',
|
||||
message: 'Se ha repuesto el stock de azúcar. Nivel actual: 50kg',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
read: true,
|
||||
priority: 'low',
|
||||
category: 'inventory',
|
||||
sender: 'Gestión de Inventario',
|
||||
actions: ['Ver Inventario']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'reminder',
|
||||
channel: 'app',
|
||||
title: 'Recordatorio de Mantenimiento',
|
||||
message: 'El horno #2 requiere mantenimiento preventivo programado para mañana',
|
||||
timestamp: '2024-01-26 07:00:00',
|
||||
read: true,
|
||||
priority: 'medium',
|
||||
category: 'maintenance',
|
||||
sender: 'Sistema de Mantenimiento',
|
||||
actions: ['Programar', 'Posponer']
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'customer',
|
||||
channel: 'app',
|
||||
title: 'Reseña de Cliente',
|
||||
message: 'Nueva reseña de 5 estrellas de María L.: "Excelente calidad y servicio"',
|
||||
timestamp: '2024-01-25 19:20:00',
|
||||
read: false,
|
||||
priority: 'low',
|
||||
category: 'feedback',
|
||||
sender: 'Sistema de Reseñas',
|
||||
actions: ['Ver Reseña', 'Responder']
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'promotion',
|
||||
channel: 'email',
|
||||
title: 'Campaña de Marketing Completada',
|
||||
message: 'La campaña "Desayunos Especiales" ha terminado con 340 interacciones',
|
||||
timestamp: '2024-01-25 16:30:00',
|
||||
read: true,
|
||||
priority: 'low',
|
||||
category: 'marketing',
|
||||
sender: 'Sistema de Marketing',
|
||||
actions: ['Ver Resultados']
|
||||
}
|
||||
];
|
||||
|
||||
const notificationStats = {
|
||||
total: notifications.length,
|
||||
unread: notifications.filter(n => !n.read).length,
|
||||
high: notifications.filter(n => n.priority === 'high').length,
|
||||
today: notifications.filter(n =>
|
||||
new Date(n.timestamp).toDateString() === new Date().toDateString()
|
||||
).length
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'Todas', count: notifications.length },
|
||||
{ id: 'unread', label: 'Sin Leer', count: notificationStats.unread },
|
||||
{ id: 'system', label: 'Sistema', count: notifications.filter(n => n.type === 'system').length },
|
||||
{ id: 'order', label: 'Pedidos', count: notifications.filter(n => n.type === 'order').length },
|
||||
{ id: 'inventory', label: 'Inventario', count: notifications.filter(n => n.type === 'inventory').length }
|
||||
];
|
||||
|
||||
const getNotificationIcon = (type: string, channel: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
|
||||
if (channel === 'email') return <Mail {...iconProps} />;
|
||||
if (channel === 'sms') return <MessageSquare {...iconProps} />;
|
||||
|
||||
switch (type) {
|
||||
case 'system': return <Settings {...iconProps} />;
|
||||
case 'order': return <Bell {...iconProps} />;
|
||||
default: return <Bell {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getChannelBadge = (channel: string) => {
|
||||
const colors = {
|
||||
app: 'blue',
|
||||
email: 'purple',
|
||||
sms: 'green'
|
||||
};
|
||||
return colors[channel as keyof typeof colors] || 'gray';
|
||||
};
|
||||
|
||||
const filteredNotifications = notifications.filter(notification => {
|
||||
let matchesTab = true;
|
||||
if (selectedTab === 'unread') {
|
||||
matchesTab = !notification.read;
|
||||
} else if (selectedTab !== 'all') {
|
||||
matchesTab = notification.type === selectedTab;
|
||||
}
|
||||
|
||||
const matchesSearch = notification.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
notification.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return matchesTab && matchesSearch;
|
||||
});
|
||||
|
||||
const handleSelectNotification = (notificationId: string) => {
|
||||
setSelectedNotifications(prev =>
|
||||
prev.includes(notificationId)
|
||||
? prev.filter(id => id !== notificationId)
|
||||
: [...prev, notificationId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedNotifications(
|
||||
selectedNotifications.length === filteredNotifications.length
|
||||
? []
|
||||
: filteredNotifications.map(n => n.id)
|
||||
);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const notificationTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - notificationTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInDays > 0) {
|
||||
return `hace ${diffInDays}d`;
|
||||
} else if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${Math.floor(diffInMs / (1000 * 60))}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Notificaciones"
|
||||
description="Centro de notificaciones y mensajes del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Preferencias
|
||||
</Button>
|
||||
<Button>
|
||||
Marcar Todas Leídas
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Notification Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{notificationStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Sin Leer</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{notificationStats.unread}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Alta Prioridad</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-error)]">{notificationStats.high}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Hoy</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{notificationStats.today}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedTab === tab.id
|
||||
? 'border-blue-600 text-[var(--color-info)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar notificaciones..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNotifications.length > 0 && (
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Marcar Leídas ({selectedNotifications.length})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archivar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{filteredNotifications.length > 0 && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNotifications.length === filteredNotifications.length}
|
||||
onChange={handleSelectAll}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
Seleccionar todas ({filteredNotifications.length})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="space-y-3">
|
||||
{filteredNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`p-4 transition-colors ${
|
||||
!notification.read ? 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20' : ''
|
||||
} ${selectedNotifications.includes(notification.id) ? 'ring-2 ring-blue-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNotifications.includes(notification.id)}
|
||||
onChange={() => handleSelectNotification(notification.id)}
|
||||
className="rounded border-[var(--border-secondary)] mt-1"
|
||||
/>
|
||||
|
||||
<div className={`p-2 rounded-lg bg-${getChannelBadge(notification.channel)}-100 mt-1`}>
|
||||
{getNotificationIcon(notification.type, notification.channel)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">
|
||||
{notification.title}
|
||||
</h3>
|
||||
{!notification.read && (
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0"></div>
|
||||
)}
|
||||
<Badge variant={getPriorityColor(notification.priority)}>
|
||||
{notification.priority === 'high' ? 'Alta' :
|
||||
notification.priority === 'medium' ? 'Media' : 'Baja'}
|
||||
</Badge>
|
||||
<Badge variant={getChannelBadge(notification.channel)}>
|
||||
{notification.channel.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{notification.message}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{formatTimeAgo(notification.timestamp)}</span>
|
||||
<span>•</span>
|
||||
<span>{notification.sender}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{notification.actions.map((action, index) => (
|
||||
<Button key={index} size="sm" variant="outline" className="text-xs">
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredNotifications.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Bell className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No hay notificaciones</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
No se encontraron notificaciones que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, Mail, MessageSquare, Settings, Archive, Trash2, MarkAsRead, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const NotificationsPage: React.FC = () => {
|
||||
const [selectedTab, setSelectedTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedNotifications, setSelectedNotifications] = useState<string[]>([]);
|
||||
|
||||
const notifications = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'system',
|
||||
channel: 'app',
|
||||
title: 'Actualización del Sistema',
|
||||
message: 'Nueva versión 2.1.0 disponible con mejoras en el módulo de inventario',
|
||||
timestamp: '2024-01-26 10:15:00',
|
||||
read: false,
|
||||
priority: 'medium',
|
||||
category: 'update',
|
||||
sender: 'Sistema',
|
||||
actions: ['Ver Detalles', 'Instalar Después']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'order',
|
||||
channel: 'email',
|
||||
title: 'Nuevo Pedido Recibido',
|
||||
message: 'Pedido #ORD-456 por €127.50 de Panadería Central',
|
||||
timestamp: '2024-01-26 09:30:00',
|
||||
read: false,
|
||||
priority: 'high',
|
||||
category: 'sales',
|
||||
sender: 'Sistema de Ventas',
|
||||
actions: ['Ver Pedido', 'Procesar']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'inventory',
|
||||
channel: 'sms',
|
||||
title: 'Stock Repuesto',
|
||||
message: 'Se ha repuesto el stock de azúcar. Nivel actual: 50kg',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
read: true,
|
||||
priority: 'low',
|
||||
category: 'inventory',
|
||||
sender: 'Gestión de Inventario',
|
||||
actions: ['Ver Inventario']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'reminder',
|
||||
channel: 'app',
|
||||
title: 'Recordatorio de Mantenimiento',
|
||||
message: 'El horno #2 requiere mantenimiento preventivo programado para mañana',
|
||||
timestamp: '2024-01-26 07:00:00',
|
||||
read: true,
|
||||
priority: 'medium',
|
||||
category: 'maintenance',
|
||||
sender: 'Sistema de Mantenimiento',
|
||||
actions: ['Programar', 'Posponer']
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'customer',
|
||||
channel: 'app',
|
||||
title: 'Reseña de Cliente',
|
||||
message: 'Nueva reseña de 5 estrellas de María L.: "Excelente calidad y servicio"',
|
||||
timestamp: '2024-01-25 19:20:00',
|
||||
read: false,
|
||||
priority: 'low',
|
||||
category: 'feedback',
|
||||
sender: 'Sistema de Reseñas',
|
||||
actions: ['Ver Reseña', 'Responder']
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'promotion',
|
||||
channel: 'email',
|
||||
title: 'Campaña de Marketing Completada',
|
||||
message: 'La campaña "Desayunos Especiales" ha terminado con 340 interacciones',
|
||||
timestamp: '2024-01-25 16:30:00',
|
||||
read: true,
|
||||
priority: 'low',
|
||||
category: 'marketing',
|
||||
sender: 'Sistema de Marketing',
|
||||
actions: ['Ver Resultados']
|
||||
}
|
||||
];
|
||||
|
||||
const notificationStats = {
|
||||
total: notifications.length,
|
||||
unread: notifications.filter(n => !n.read).length,
|
||||
high: notifications.filter(n => n.priority === 'high').length,
|
||||
today: notifications.filter(n =>
|
||||
new Date(n.timestamp).toDateString() === new Date().toDateString()
|
||||
).length
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'Todas', count: notifications.length },
|
||||
{ id: 'unread', label: 'Sin Leer', count: notificationStats.unread },
|
||||
{ id: 'system', label: 'Sistema', count: notifications.filter(n => n.type === 'system').length },
|
||||
{ id: 'order', label: 'Pedidos', count: notifications.filter(n => n.type === 'order').length },
|
||||
{ id: 'inventory', label: 'Inventario', count: notifications.filter(n => n.type === 'inventory').length }
|
||||
];
|
||||
|
||||
const getNotificationIcon = (type: string, channel: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
|
||||
if (channel === 'email') return <Mail {...iconProps} />;
|
||||
if (channel === 'sms') return <MessageSquare {...iconProps} />;
|
||||
|
||||
switch (type) {
|
||||
case 'system': return <Settings {...iconProps} />;
|
||||
case 'order': return <Bell {...iconProps} />;
|
||||
default: return <Bell {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getChannelBadge = (channel: string) => {
|
||||
const colors = {
|
||||
app: 'blue',
|
||||
email: 'purple',
|
||||
sms: 'green'
|
||||
};
|
||||
return colors[channel as keyof typeof colors] || 'gray';
|
||||
};
|
||||
|
||||
const filteredNotifications = notifications.filter(notification => {
|
||||
let matchesTab = true;
|
||||
if (selectedTab === 'unread') {
|
||||
matchesTab = !notification.read;
|
||||
} else if (selectedTab !== 'all') {
|
||||
matchesTab = notification.type === selectedTab;
|
||||
}
|
||||
|
||||
const matchesSearch = notification.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
notification.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return matchesTab && matchesSearch;
|
||||
});
|
||||
|
||||
const handleSelectNotification = (notificationId: string) => {
|
||||
setSelectedNotifications(prev =>
|
||||
prev.includes(notificationId)
|
||||
? prev.filter(id => id !== notificationId)
|
||||
: [...prev, notificationId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedNotifications(
|
||||
selectedNotifications.length === filteredNotifications.length
|
||||
? []
|
||||
: filteredNotifications.map(n => n.id)
|
||||
);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const notificationTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - notificationTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInDays > 0) {
|
||||
return `hace ${diffInDays}d`;
|
||||
} else if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${Math.floor(diffInMs / (1000 * 60))}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Notificaciones"
|
||||
description="Centro de notificaciones y mensajes del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Preferencias
|
||||
</Button>
|
||||
<Button>
|
||||
Marcar Todas Leídas
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Notification Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{notificationStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Sin Leer</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{notificationStats.unread}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<MarkAsRead className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Alta Prioridad</p>
|
||||
<p className="text-3xl font-bold text-red-600">{notificationStats.high}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Hoy</p>
|
||||
<p className="text-3xl font-bold text-green-600">{notificationStats.today}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
selectedTab === tab.id
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar notificaciones..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNotifications.length > 0 && (
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<MarkAsRead className="w-4 h-4 mr-2" />
|
||||
Marcar Leídas ({selectedNotifications.length})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archivar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{filteredNotifications.length > 0 && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNotifications.length === filteredNotifications.length}
|
||||
onChange={handleSelectAll}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
Seleccionar todas ({filteredNotifications.length})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="space-y-3">
|
||||
{filteredNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`p-4 transition-colors ${
|
||||
!notification.read ? 'bg-blue-50 border-blue-200' : ''
|
||||
} ${selectedNotifications.includes(notification.id) ? 'ring-2 ring-blue-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNotifications.includes(notification.id)}
|
||||
onChange={() => handleSelectNotification(notification.id)}
|
||||
className="rounded border-gray-300 mt-1"
|
||||
/>
|
||||
|
||||
<div className={`p-2 rounded-lg bg-${getChannelBadge(notification.channel)}-100 mt-1`}>
|
||||
{getNotificationIcon(notification.type, notification.channel)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
||||
{notification.title}
|
||||
</h3>
|
||||
{!notification.read && (
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0"></div>
|
||||
)}
|
||||
<Badge variant={getPriorityColor(notification.priority)}>
|
||||
{notification.priority === 'high' ? 'Alta' :
|
||||
notification.priority === 'medium' ? 'Media' : 'Baja'}
|
||||
</Badge>
|
||||
<Badge variant={getChannelBadge(notification.channel)}>
|
||||
{notification.channel.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 mb-2">{notification.message}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>{formatTimeAgo(notification.timestamp)}</span>
|
||||
<span>•</span>
|
||||
<span>{notification.sender}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{notification.actions.map((action, index) => (
|
||||
<Button key={index} size="sm" variant="outline" className="text-xs">
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredNotifications.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay notificaciones</h3>
|
||||
<p className="text-gray-600">
|
||||
No se encontraron notificaciones que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as NotificationsPage } from './NotificationsPage';
|
||||
@@ -0,0 +1,388 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PreferencesPage: React.FC = () => {
|
||||
const [preferences, setPreferences] = useState({
|
||||
notifications: {
|
||||
inventory: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
sales: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'hourly'
|
||||
},
|
||||
production: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
system: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'daily'
|
||||
},
|
||||
marketing: {
|
||||
app: false,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'weekly'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
doNotDisturb: false,
|
||||
quietHours: {
|
||||
enabled: false,
|
||||
start: '22:00',
|
||||
end: '07:00'
|
||||
},
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
soundEnabled: true,
|
||||
vibrationEnabled: true
|
||||
},
|
||||
channels: {
|
||||
email: 'panaderia@example.com',
|
||||
phone: '+34 600 123 456',
|
||||
slack: false,
|
||||
webhook: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'Inventario',
|
||||
description: 'Alertas de stock, reposiciones y vencimientos',
|
||||
icon: '📦'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
name: 'Ventas',
|
||||
description: 'Pedidos, transacciones y reportes de ventas',
|
||||
icon: '💰'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'Producción',
|
||||
description: 'Hornadas, calidad y tiempos de producción',
|
||||
icon: '🍞'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
name: 'Sistema',
|
||||
description: 'Actualizaciones, mantenimiento y errores',
|
||||
icon: '⚙️'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing',
|
||||
description: 'Campañas, promociones y análisis',
|
||||
icon: '📢'
|
||||
}
|
||||
];
|
||||
|
||||
const frequencies = [
|
||||
{ value: 'immediate', label: 'Inmediato' },
|
||||
{ value: 'hourly', label: 'Cada hora' },
|
||||
{ value: 'daily', label: 'Diario' },
|
||||
{ value: 'weekly', label: 'Semanal' }
|
||||
];
|
||||
|
||||
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
[channel]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (category: string, frequency: string) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
frequency
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalChange = (setting: string, value: any) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
global: {
|
||||
...prev.global,
|
||||
[setting]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleChannelChange = (channel: string, value: string | boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving preferences:', preferences);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getChannelIcon = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'app':
|
||||
return <Bell className="w-4 h-4" />;
|
||||
case 'email':
|
||||
return <Mail className="w-4 h-4" />;
|
||||
case 'sms':
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Preferencias de Comunicación"
|
||||
description="Configura cómo y cuándo recibir notificaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configuración General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.doNotDisturb}
|
||||
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">No molestar</span>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">Silencia todas las notificaciones</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.soundEnabled}
|
||||
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Sonidos</span>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">Reproducir sonidos de notificación</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.quietHours.enabled}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
enabled: e.target.checked
|
||||
})}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Horas silenciosas</span>
|
||||
</label>
|
||||
{preferences.global.quietHours.enabled && (
|
||||
<div className="flex space-x-4 ml-6">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Desde</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.start}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Hasta</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.end}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Channel Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Canales de Comunicación</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={preferences.channels.email}
|
||||
onChange={(e) => handleChannelChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="tu-email@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Teléfono (SMS)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={preferences.channels.phone}
|
||||
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="+34 600 123 456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={preferences.channels.webhook}
|
||||
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="https://tu-webhook.com/notifications"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">URL para recibir notificaciones JSON</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Category Preferences */}
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
||||
|
||||
return (
|
||||
<Card key={category.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-2xl">{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">{category.name}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">{category.description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Channel toggles */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Canales</h4>
|
||||
<div className="flex space-x-6">
|
||||
{['app', 'email', 'sms'].map((channel) => (
|
||||
<label key={channel} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
||||
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<div className="flex items-center space-x-1">
|
||||
{getChannelIcon(channel)}
|
||||
<span className="text-sm text-[var(--text-secondary)] capitalize">{channel}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Frecuencia</h4>
|
||||
<select
|
||||
value={categoryPrefs.frequency}
|
||||
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
>
|
||||
{frequencies.map((freq) => (
|
||||
<option key={freq.value} value={freq.value}>
|
||||
{freq.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-[var(--color-info)] bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesPage;
|
||||
@@ -0,0 +1,388 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PreferencesPage: React.FC = () => {
|
||||
const [preferences, setPreferences] = useState({
|
||||
notifications: {
|
||||
inventory: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
sales: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'hourly'
|
||||
},
|
||||
production: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
system: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'daily'
|
||||
},
|
||||
marketing: {
|
||||
app: false,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'weekly'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
doNotDisturb: false,
|
||||
quietHours: {
|
||||
enabled: false,
|
||||
start: '22:00',
|
||||
end: '07:00'
|
||||
},
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
soundEnabled: true,
|
||||
vibrationEnabled: true
|
||||
},
|
||||
channels: {
|
||||
email: 'panaderia@example.com',
|
||||
phone: '+34 600 123 456',
|
||||
slack: false,
|
||||
webhook: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'Inventario',
|
||||
description: 'Alertas de stock, reposiciones y vencimientos',
|
||||
icon: '📦'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
name: 'Ventas',
|
||||
description: 'Pedidos, transacciones y reportes de ventas',
|
||||
icon: '💰'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'Producción',
|
||||
description: 'Hornadas, calidad y tiempos de producción',
|
||||
icon: '🍞'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
name: 'Sistema',
|
||||
description: 'Actualizaciones, mantenimiento y errores',
|
||||
icon: '⚙️'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing',
|
||||
description: 'Campañas, promociones y análisis',
|
||||
icon: '📢'
|
||||
}
|
||||
];
|
||||
|
||||
const frequencies = [
|
||||
{ value: 'immediate', label: 'Inmediato' },
|
||||
{ value: 'hourly', label: 'Cada hora' },
|
||||
{ value: 'daily', label: 'Diario' },
|
||||
{ value: 'weekly', label: 'Semanal' }
|
||||
];
|
||||
|
||||
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
[channel]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (category: string, frequency: string) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
frequency
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalChange = (setting: string, value: any) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
global: {
|
||||
...prev.global,
|
||||
[setting]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleChannelChange = (channel: string, value: string | boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving preferences:', preferences);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getChannelIcon = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'app':
|
||||
return <Bell className="w-4 h-4" />;
|
||||
case 'email':
|
||||
return <Mail className="w-4 h-4" />;
|
||||
case 'sms':
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Preferencias de Comunicación"
|
||||
description="Configura cómo y cuándo recibir notificaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Configuración General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.doNotDisturb}
|
||||
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">No molestar</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Silencia todas las notificaciones</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.soundEnabled}
|
||||
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Sonidos</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Reproducir sonidos de notificación</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.quietHours.enabled}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
enabled: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Horas silenciosas</span>
|
||||
</label>
|
||||
{preferences.global.quietHours.enabled && (
|
||||
<div className="flex space-x-4 ml-6">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Desde</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.start}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Hasta</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.end}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Channel Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Canales de Comunicación</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={preferences.channels.email}
|
||||
onChange={(e) => handleChannelChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="tu-email@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Teléfono (SMS)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={preferences.channels.phone}
|
||||
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="+34 600 123 456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={preferences.channels.webhook}
|
||||
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="https://tu-webhook.com/notifications"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">URL para recibir notificaciones JSON</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Category Preferences */}
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
||||
|
||||
return (
|
||||
<Card key={category.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-2xl">{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{category.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{category.description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Channel toggles */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Canales</h4>
|
||||
<div className="flex space-x-6">
|
||||
{['app', 'email', 'sms'].map((channel) => (
|
||||
<label key={channel} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
||||
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<div className="flex items-center space-x-1">
|
||||
{getChannelIcon(channel)}
|
||||
<span className="text-sm text-gray-700 capitalize">{channel}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Frecuencia</h4>
|
||||
<select
|
||||
value={categoryPrefs.frequency}
|
||||
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{frequencies.map((freq) => (
|
||||
<option key={freq.value} value={freq.value}>
|
||||
{freq.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-blue-600 bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesPage;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PreferencesPage } from './PreferencesPage';
|
||||
312
frontend/src/pages/app/data/events/EventsPage.tsx
Normal file
312
frontend/src/pages/app/data/events/EventsPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Activity, Filter, Download, Eye, BarChart3 } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const EventsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
|
||||
const events = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
category: 'sales',
|
||||
type: 'order_completed',
|
||||
title: 'Pedido Completado',
|
||||
description: 'Pedido #ORD-456 completado por €127.50',
|
||||
metadata: {
|
||||
orderId: 'ORD-456',
|
||||
amount: 127.50,
|
||||
customer: 'María González',
|
||||
items: 8
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
category: 'production',
|
||||
type: 'batch_started',
|
||||
title: 'Lote Iniciado',
|
||||
description: 'Iniciado lote de croissants CR-024',
|
||||
metadata: {
|
||||
batchId: 'CR-024',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
expectedDuration: '2.5h'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
category: 'inventory',
|
||||
type: 'stock_updated',
|
||||
title: 'Stock Actualizado',
|
||||
description: 'Repuesto stock de harina - Nivel: 50kg',
|
||||
metadata: {
|
||||
item: 'Harina de Trigo',
|
||||
previousLevel: '5kg',
|
||||
newLevel: '50kg',
|
||||
supplier: 'Molinos del Sur'
|
||||
},
|
||||
severity: 'success'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
category: 'system',
|
||||
type: 'user_login',
|
||||
title: 'Inicio de Sesión',
|
||||
description: 'Usuario admin ha iniciado sesión',
|
||||
metadata: {
|
||||
userId: 'admin',
|
||||
ipAddress: '192.168.1.100',
|
||||
userAgent: 'Chrome/120.0',
|
||||
location: 'Madrid, ES'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: '2024-01-25 19:20:00',
|
||||
category: 'sales',
|
||||
type: 'payment_processed',
|
||||
title: 'Pago Procesado',
|
||||
description: 'Pago de €45.80 procesado exitosamente',
|
||||
metadata: {
|
||||
amount: 45.80,
|
||||
method: 'Tarjeta',
|
||||
reference: 'PAY-789',
|
||||
customer: 'Juan Pérez'
|
||||
},
|
||||
severity: 'success'
|
||||
}
|
||||
];
|
||||
|
||||
const eventStats = {
|
||||
total: events.length,
|
||||
today: events.filter(e =>
|
||||
new Date(e.timestamp).toDateString() === new Date().toDateString()
|
||||
).length,
|
||||
sales: events.filter(e => e.category === 'sales').length,
|
||||
production: events.filter(e => e.category === 'production').length,
|
||||
system: events.filter(e => e.category === 'system').length
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todos', count: events.length },
|
||||
{ value: 'sales', label: 'Ventas', count: eventStats.sales },
|
||||
{ value: 'production', label: 'Producción', count: eventStats.production },
|
||||
{ value: 'inventory', label: 'Inventario', count: events.filter(e => e.category === 'inventory').length },
|
||||
{ value: 'system', label: 'Sistema', count: eventStats.system }
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'success': return 'green';
|
||||
case 'warning': return 'yellow';
|
||||
case 'error': return 'red';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
switch (category) {
|
||||
case 'sales': return <BarChart3 {...iconProps} />;
|
||||
case 'production': return <Activity {...iconProps} />;
|
||||
case 'inventory': return <Calendar {...iconProps} />;
|
||||
default: return <Activity {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = selectedCategory === 'all'
|
||||
? events
|
||||
: events.filter(event => event.category === selectedCategory);
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const eventTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - eventTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInDays > 0) {
|
||||
return `hace ${diffInDays}d`;
|
||||
} else if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${Math.floor(diffInMs / (1000 * 60))}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Registro de Eventos"
|
||||
description="Seguimiento de todas las actividades y eventos del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros Avanzados
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Event Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Eventos</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{eventStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Hoy</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{eventStats.today}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{eventStats.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Producción</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{eventStats.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="all">Todos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEvents.map((event) => (
|
||||
<Card key={event.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className={`p-2 rounded-lg bg-${getSeverityColor(event.severity)}-100`}>
|
||||
{getCategoryIcon(event.category)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{event.title}</h3>
|
||||
<Badge variant={getSeverityColor(event.severity)}>
|
||||
{event.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--text-secondary)] mb-3">{event.description}</p>
|
||||
|
||||
{/* Event Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<div key={key} className="bg-[var(--bg-secondary)] p-3 rounded-lg">
|
||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wider mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-[var(--text-tertiary)]">
|
||||
<span>{formatTimeAgo(event.timestamp)}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{new Date(event.timestamp).toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Activity className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No hay eventos</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
No se encontraron eventos para el período y categoría seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventsPage;
|
||||
312
frontend/src/pages/app/data/events/EventsPage.tsx.backup
Normal file
312
frontend/src/pages/app/data/events/EventsPage.tsx.backup
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Activity, Filter, Download, Eye, BarChart3 } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const EventsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
|
||||
const events = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
category: 'sales',
|
||||
type: 'order_completed',
|
||||
title: 'Pedido Completado',
|
||||
description: 'Pedido #ORD-456 completado por €127.50',
|
||||
metadata: {
|
||||
orderId: 'ORD-456',
|
||||
amount: 127.50,
|
||||
customer: 'María González',
|
||||
items: 8
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
category: 'production',
|
||||
type: 'batch_started',
|
||||
title: 'Lote Iniciado',
|
||||
description: 'Iniciado lote de croissants CR-024',
|
||||
metadata: {
|
||||
batchId: 'CR-024',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
expectedDuration: '2.5h'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
category: 'inventory',
|
||||
type: 'stock_updated',
|
||||
title: 'Stock Actualizado',
|
||||
description: 'Repuesto stock de harina - Nivel: 50kg',
|
||||
metadata: {
|
||||
item: 'Harina de Trigo',
|
||||
previousLevel: '5kg',
|
||||
newLevel: '50kg',
|
||||
supplier: 'Molinos del Sur'
|
||||
},
|
||||
severity: 'success'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
category: 'system',
|
||||
type: 'user_login',
|
||||
title: 'Inicio de Sesión',
|
||||
description: 'Usuario admin ha iniciado sesión',
|
||||
metadata: {
|
||||
userId: 'admin',
|
||||
ipAddress: '192.168.1.100',
|
||||
userAgent: 'Chrome/120.0',
|
||||
location: 'Madrid, ES'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: '2024-01-25 19:20:00',
|
||||
category: 'sales',
|
||||
type: 'payment_processed',
|
||||
title: 'Pago Procesado',
|
||||
description: 'Pago de €45.80 procesado exitosamente',
|
||||
metadata: {
|
||||
amount: 45.80,
|
||||
method: 'Tarjeta',
|
||||
reference: 'PAY-789',
|
||||
customer: 'Juan Pérez'
|
||||
},
|
||||
severity: 'success'
|
||||
}
|
||||
];
|
||||
|
||||
const eventStats = {
|
||||
total: events.length,
|
||||
today: events.filter(e =>
|
||||
new Date(e.timestamp).toDateString() === new Date().toDateString()
|
||||
).length,
|
||||
sales: events.filter(e => e.category === 'sales').length,
|
||||
production: events.filter(e => e.category === 'production').length,
|
||||
system: events.filter(e => e.category === 'system').length
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todos', count: events.length },
|
||||
{ value: 'sales', label: 'Ventas', count: eventStats.sales },
|
||||
{ value: 'production', label: 'Producción', count: eventStats.production },
|
||||
{ value: 'inventory', label: 'Inventario', count: events.filter(e => e.category === 'inventory').length },
|
||||
{ value: 'system', label: 'Sistema', count: eventStats.system }
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'success': return 'green';
|
||||
case 'warning': return 'yellow';
|
||||
case 'error': return 'red';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
switch (category) {
|
||||
case 'sales': return <BarChart3 {...iconProps} />;
|
||||
case 'production': return <Activity {...iconProps} />;
|
||||
case 'inventory': return <Calendar {...iconProps} />;
|
||||
default: return <Activity {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = selectedCategory === 'all'
|
||||
? events
|
||||
: events.filter(event => event.category === selectedCategory);
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const eventTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - eventTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInDays > 0) {
|
||||
return `hace ${diffInDays}d`;
|
||||
} else if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${Math.floor(diffInMs / (1000 * 60))}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Registro de Eventos"
|
||||
description="Seguimiento de todas las actividades y eventos del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros Avanzados
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Event Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Eventos</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{eventStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Hoy</p>
|
||||
<p className="text-3xl font-bold text-green-600">{eventStats.today}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{eventStats.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Producción</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{eventStats.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="all">Todos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEvents.map((event) => (
|
||||
<Card key={event.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className={`p-2 rounded-lg bg-${getSeverityColor(event.severity)}-100`}>
|
||||
{getCategoryIcon(event.category)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{event.title}</h3>
|
||||
<Badge variant={getSeverityColor(event.severity)}>
|
||||
{event.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-3">{event.description}</p>
|
||||
|
||||
{/* Event Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<div key={key} className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span>{formatTimeAgo(event.timestamp)}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{new Date(event.timestamp).toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Activity className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay eventos</h3>
|
||||
<p className="text-gray-600">
|
||||
No se encontraron eventos para el período y categoría seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventsPage;
|
||||
1
frontend/src/pages/app/data/events/index.ts
Normal file
1
frontend/src/pages/app/data/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as EventsPage } from './EventsPage';
|
||||
336
frontend/src/pages/app/data/traffic/TrafficPage.tsx
Normal file
336
frontend/src/pages/app/data/traffic/TrafficPage.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Clock, TrendingUp, MapPin, Calendar, BarChart3, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TrafficPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedMetric, setSelectedMetric] = useState('visitors');
|
||||
|
||||
const trafficData = {
|
||||
totalVisitors: 2847,
|
||||
peakHour: '12:00',
|
||||
averageVisitDuration: '23min',
|
||||
busyDays: ['Viernes', 'Sábado'],
|
||||
conversionRate: 68.4
|
||||
};
|
||||
|
||||
const hourlyTraffic = [
|
||||
{ hour: '07:00', visitors: 15, sales: 12, duration: '18min' },
|
||||
{ hour: '08:00', visitors: 32, sales: 24, duration: '22min' },
|
||||
{ hour: '09:00', visitors: 45, sales: 28, duration: '25min' },
|
||||
{ hour: '10:00', visitors: 38, sales: 25, duration: '24min' },
|
||||
{ hour: '11:00', visitors: 52, sales: 35, duration: '26min' },
|
||||
{ hour: '12:00', visitors: 78, sales: 54, duration: '28min' },
|
||||
{ hour: '13:00', visitors: 85, sales: 58, duration: '30min' },
|
||||
{ hour: '14:00', visitors: 62, sales: 42, duration: '27min' },
|
||||
{ hour: '15:00', visitors: 48, sales: 32, duration: '25min' },
|
||||
{ hour: '16:00', visitors: 55, sales: 38, duration: '26min' },
|
||||
{ hour: '17:00', visitors: 68, sales: 46, duration: '29min' },
|
||||
{ hour: '18:00', visitors: 74, sales: 52, duration: '31min' },
|
||||
{ hour: '19:00', visitors: 56, sales: 39, duration: '28min' },
|
||||
{ hour: '20:00', visitors: 28, sales: 18, duration: '22min' }
|
||||
];
|
||||
|
||||
const dailyTraffic = [
|
||||
{ day: 'Lun', visitors: 245, sales: 168, conversion: 68.6, avgDuration: '22min' },
|
||||
{ day: 'Mar', visitors: 289, sales: 195, conversion: 67.5, avgDuration: '24min' },
|
||||
{ day: 'Mié', visitors: 321, sales: 218, conversion: 67.9, avgDuration: '25min' },
|
||||
{ day: 'Jue', visitors: 356, sales: 242, conversion: 68.0, avgDuration: '26min' },
|
||||
{ day: 'Vie', visitors: 445, sales: 312, conversion: 70.1, avgDuration: '28min' },
|
||||
{ day: 'Sáb', visitors: 498, sales: 348, conversion: 69.9, avgDuration: '30min' },
|
||||
{ day: 'Dom', visitors: 398, sales: 265, conversion: 66.6, avgDuration: '27min' }
|
||||
];
|
||||
|
||||
const trafficSources = [
|
||||
{ source: 'Pie', visitors: 1245, percentage: 43.7, trend: 5.2 },
|
||||
{ source: 'Búsqueda Local', visitors: 687, percentage: 24.1, trend: 12.3 },
|
||||
{ source: 'Recomendaciones', visitors: 423, percentage: 14.9, trend: -2.1 },
|
||||
{ source: 'Redes Sociales', visitors: 298, percentage: 10.5, trend: 8.7 },
|
||||
{ source: 'Publicidad', visitors: 194, percentage: 6.8, trend: 15.4 }
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{
|
||||
segment: 'Regulares Matutinos',
|
||||
count: 145,
|
||||
percentage: 24.2,
|
||||
peakHours: ['07:00-09:00'],
|
||||
avgSpend: 12.50,
|
||||
frequency: 'Diaria'
|
||||
},
|
||||
{
|
||||
segment: 'Familia Fin de Semana',
|
||||
count: 198,
|
||||
percentage: 33.1,
|
||||
peakHours: ['10:00-13:00'],
|
||||
avgSpend: 28.90,
|
||||
frequency: 'Semanal'
|
||||
},
|
||||
{
|
||||
segment: 'Oficinistas Almuerzo',
|
||||
count: 112,
|
||||
percentage: 18.7,
|
||||
peakHours: ['12:00-14:00'],
|
||||
avgSpend: 8.75,
|
||||
frequency: '2-3x semana'
|
||||
},
|
||||
{
|
||||
segment: 'Clientes Ocasionales',
|
||||
count: 143,
|
||||
percentage: 23.9,
|
||||
peakHours: ['16:00-19:00'],
|
||||
avgSpend: 15.20,
|
||||
frequency: 'Mensual'
|
||||
}
|
||||
];
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
return trend >= 0 ? '↗' : '↘';
|
||||
};
|
||||
|
||||
const maxVisitors = Math.max(...hourlyTraffic.map(h => h.visitors));
|
||||
const maxDailyVisitors = Math.max(...dailyTraffic.map(d => d.visitors));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Tráfico"
|
||||
description="Monitorea los patrones de visitas y flujo de clientes"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Traffic Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Visitantes Totales</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{trafficData.totalVisitors.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Hora Pico</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{trafficData.peakHour}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Duración Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{trafficData.averageVisitDuration}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Conversión</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{trafficData.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Días Ocupados</p>
|
||||
<p className="text-sm font-bold text-[var(--color-error)]">{trafficData.busyDays.join(', ')}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="year">Este Año</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="visitors">Visitantes</option>
|
||||
<option value="sales">Ventas</option>
|
||||
<option value="duration">Duración</option>
|
||||
<option value="conversion">Conversión</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Hourly Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Tráfico por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{hourlyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-[var(--color-info)]/50 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxVisitors) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Daily Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Tráfico Semanal</h3>
|
||||
<div className="h-64 flex items-end space-x-2 justify-between">
|
||||
{dailyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-green-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxDailyVisitors) * 200}px`,
|
||||
minHeight: '8px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-sm text-[var(--text-secondary)] mt-2 font-medium">
|
||||
{data.day}
|
||||
</span>
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
{data.conversion}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Traffic Sources */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Fuentes de Tráfico</h3>
|
||||
<div className="space-y-3">
|
||||
{trafficSources.map((source, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-[var(--color-info)]/50 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{source.source}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{source.visitors} visitantes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{source.percentage}%</p>
|
||||
<div className={`text-xs flex items-center ${getTrendColor(source.trend)}`}>
|
||||
<span>{getTrendIcon(source.trend)} {Math.abs(source.trend).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{segment.segment}</h4>
|
||||
<Badge variant="blue">{segment.percentage}%</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Clientes</p>
|
||||
<p className="font-medium">{segment.count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Gasto Promedio</p>
|
||||
<p className="font-medium">€{segment.avgSpend}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Horario Pico</p>
|
||||
<p className="font-medium">{segment.peakHours.join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)]">Frecuencia</p>
|
||||
<p className="font-medium">{segment.frequency}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Traffic Heat Map placeholder */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Mapa de Calor - Zonas de la Panadería</h3>
|
||||
<div className="h-64 bg-[var(--bg-tertiary)] rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">Visualización de zonas de mayor tráfico</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">Entrada: 45% • Mostrador: 32% • Zona sentada: 23%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPage;
|
||||
336
frontend/src/pages/app/data/traffic/TrafficPage.tsx.backup
Normal file
336
frontend/src/pages/app/data/traffic/TrafficPage.tsx.backup
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Clock, TrendingUp, MapPin, Calendar, BarChart3, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TrafficPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedMetric, setSelectedMetric] = useState('visitors');
|
||||
|
||||
const trafficData = {
|
||||
totalVisitors: 2847,
|
||||
peakHour: '12:00',
|
||||
averageVisitDuration: '23min',
|
||||
busyDays: ['Viernes', 'Sábado'],
|
||||
conversionRate: 68.4
|
||||
};
|
||||
|
||||
const hourlyTraffic = [
|
||||
{ hour: '07:00', visitors: 15, sales: 12, duration: '18min' },
|
||||
{ hour: '08:00', visitors: 32, sales: 24, duration: '22min' },
|
||||
{ hour: '09:00', visitors: 45, sales: 28, duration: '25min' },
|
||||
{ hour: '10:00', visitors: 38, sales: 25, duration: '24min' },
|
||||
{ hour: '11:00', visitors: 52, sales: 35, duration: '26min' },
|
||||
{ hour: '12:00', visitors: 78, sales: 54, duration: '28min' },
|
||||
{ hour: '13:00', visitors: 85, sales: 58, duration: '30min' },
|
||||
{ hour: '14:00', visitors: 62, sales: 42, duration: '27min' },
|
||||
{ hour: '15:00', visitors: 48, sales: 32, duration: '25min' },
|
||||
{ hour: '16:00', visitors: 55, sales: 38, duration: '26min' },
|
||||
{ hour: '17:00', visitors: 68, sales: 46, duration: '29min' },
|
||||
{ hour: '18:00', visitors: 74, sales: 52, duration: '31min' },
|
||||
{ hour: '19:00', visitors: 56, sales: 39, duration: '28min' },
|
||||
{ hour: '20:00', visitors: 28, sales: 18, duration: '22min' }
|
||||
];
|
||||
|
||||
const dailyTraffic = [
|
||||
{ day: 'Lun', visitors: 245, sales: 168, conversion: 68.6, avgDuration: '22min' },
|
||||
{ day: 'Mar', visitors: 289, sales: 195, conversion: 67.5, avgDuration: '24min' },
|
||||
{ day: 'Mié', visitors: 321, sales: 218, conversion: 67.9, avgDuration: '25min' },
|
||||
{ day: 'Jue', visitors: 356, sales: 242, conversion: 68.0, avgDuration: '26min' },
|
||||
{ day: 'Vie', visitors: 445, sales: 312, conversion: 70.1, avgDuration: '28min' },
|
||||
{ day: 'Sáb', visitors: 498, sales: 348, conversion: 69.9, avgDuration: '30min' },
|
||||
{ day: 'Dom', visitors: 398, sales: 265, conversion: 66.6, avgDuration: '27min' }
|
||||
];
|
||||
|
||||
const trafficSources = [
|
||||
{ source: 'Pie', visitors: 1245, percentage: 43.7, trend: 5.2 },
|
||||
{ source: 'Búsqueda Local', visitors: 687, percentage: 24.1, trend: 12.3 },
|
||||
{ source: 'Recomendaciones', visitors: 423, percentage: 14.9, trend: -2.1 },
|
||||
{ source: 'Redes Sociales', visitors: 298, percentage: 10.5, trend: 8.7 },
|
||||
{ source: 'Publicidad', visitors: 194, percentage: 6.8, trend: 15.4 }
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{
|
||||
segment: 'Regulares Matutinos',
|
||||
count: 145,
|
||||
percentage: 24.2,
|
||||
peakHours: ['07:00-09:00'],
|
||||
avgSpend: 12.50,
|
||||
frequency: 'Diaria'
|
||||
},
|
||||
{
|
||||
segment: 'Familia Fin de Semana',
|
||||
count: 198,
|
||||
percentage: 33.1,
|
||||
peakHours: ['10:00-13:00'],
|
||||
avgSpend: 28.90,
|
||||
frequency: 'Semanal'
|
||||
},
|
||||
{
|
||||
segment: 'Oficinistas Almuerzo',
|
||||
count: 112,
|
||||
percentage: 18.7,
|
||||
peakHours: ['12:00-14:00'],
|
||||
avgSpend: 8.75,
|
||||
frequency: '2-3x semana'
|
||||
},
|
||||
{
|
||||
segment: 'Clientes Ocasionales',
|
||||
count: 143,
|
||||
percentage: 23.9,
|
||||
peakHours: ['16:00-19:00'],
|
||||
avgSpend: 15.20,
|
||||
frequency: 'Mensual'
|
||||
}
|
||||
];
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
return trend >= 0 ? '↗' : '↘';
|
||||
};
|
||||
|
||||
const maxVisitors = Math.max(...hourlyTraffic.map(h => h.visitors));
|
||||
const maxDailyVisitors = Math.max(...dailyTraffic.map(d => d.visitors));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Tráfico"
|
||||
description="Monitorea los patrones de visitas y flujo de clientes"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Traffic Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Visitantes Totales</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{trafficData.totalVisitors.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Hora Pico</p>
|
||||
<p className="text-3xl font-bold text-green-600">{trafficData.peakHour}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Duración Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{trafficData.averageVisitDuration}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Conversión</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{trafficData.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Días Ocupados</p>
|
||||
<p className="text-sm font-bold text-red-600">{trafficData.busyDays.join(', ')}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="year">Este Año</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Métrica</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="visitors">Visitantes</option>
|
||||
<option value="sales">Ventas</option>
|
||||
<option value="duration">Duración</option>
|
||||
<option value="conversion">Conversión</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Hourly Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tráfico por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{hourlyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxVisitors) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Daily Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tráfico Semanal</h3>
|
||||
<div className="h-64 flex items-end space-x-2 justify-between">
|
||||
{dailyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-green-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxDailyVisitors) * 200}px`,
|
||||
minHeight: '8px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-sm text-gray-700 mt-2 font-medium">
|
||||
{data.day}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.conversion}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Traffic Sources */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Fuentes de Tráfico</h3>
|
||||
<div className="space-y-3">
|
||||
{trafficSources.map((source, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{source.source}</p>
|
||||
<p className="text-xs text-gray-500">{source.visitors} visitantes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">{source.percentage}%</p>
|
||||
<div className={`text-xs flex items-center ${getTrendColor(source.trend)}`}>
|
||||
<span>{getTrendIcon(source.trend)} {Math.abs(source.trend).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-900">{segment.segment}</h4>
|
||||
<Badge variant="blue">{segment.percentage}%</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Clientes</p>
|
||||
<p className="font-medium">{segment.count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Gasto Promedio</p>
|
||||
<p className="font-medium">€{segment.avgSpend}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Horario Pico</p>
|
||||
<p className="font-medium">{segment.peakHours.join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Frecuencia</p>
|
||||
<p className="font-medium">{segment.frequency}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Traffic Heat Map placeholder */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Mapa de Calor - Zonas de la Panadería</h3>
|
||||
<div className="h-64 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Visualización de zonas de mayor tráfico</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Entrada: 45% • Mostrador: 32% • Zona sentada: 23%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPage;
|
||||
1
frontend/src/pages/app/data/traffic/index.ts
Normal file
1
frontend/src/pages/app/data/traffic/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TrafficPage } from './TrafficPage';
|
||||
423
frontend/src/pages/app/data/weather/WeatherPage.tsx
Normal file
423
frontend/src/pages/app/data/weather/WeatherPage.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Cloud, Sun, CloudRain, Thermometer, Wind, Droplets, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const WeatherPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
|
||||
const currentWeather = {
|
||||
temperature: 18,
|
||||
condition: 'partly-cloudy',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
pressure: 1013,
|
||||
uvIndex: 4,
|
||||
visibility: 10,
|
||||
description: 'Parcialmente nublado'
|
||||
};
|
||||
|
||||
const forecast = [
|
||||
{
|
||||
date: '2024-01-27',
|
||||
day: 'Sábado',
|
||||
condition: 'sunny',
|
||||
tempMax: 22,
|
||||
tempMin: 12,
|
||||
humidity: 45,
|
||||
precipitation: 0,
|
||||
wind: 8,
|
||||
impact: 'high-demand',
|
||||
recommendation: 'Incrementar producción de helados y bebidas frías'
|
||||
},
|
||||
{
|
||||
date: '2024-01-28',
|
||||
day: 'Domingo',
|
||||
condition: 'partly-cloudy',
|
||||
tempMax: 19,
|
||||
tempMin: 11,
|
||||
humidity: 55,
|
||||
precipitation: 20,
|
||||
wind: 15,
|
||||
impact: 'normal',
|
||||
recommendation: 'Producción estándar'
|
||||
},
|
||||
{
|
||||
date: '2024-01-29',
|
||||
day: 'Lunes',
|
||||
condition: 'rainy',
|
||||
tempMax: 15,
|
||||
tempMin: 8,
|
||||
humidity: 85,
|
||||
precipitation: 80,
|
||||
wind: 22,
|
||||
impact: 'comfort-food',
|
||||
recommendation: 'Aumentar sopas, chocolates calientes y pan recién horneado'
|
||||
},
|
||||
{
|
||||
date: '2024-01-30',
|
||||
day: 'Martes',
|
||||
condition: 'cloudy',
|
||||
tempMax: 16,
|
||||
tempMin: 9,
|
||||
humidity: 70,
|
||||
precipitation: 40,
|
||||
wind: 18,
|
||||
impact: 'moderate',
|
||||
recommendation: 'Enfoque en productos de interior'
|
||||
},
|
||||
{
|
||||
date: '2024-01-31',
|
||||
day: 'Miércoles',
|
||||
condition: 'sunny',
|
||||
tempMax: 24,
|
||||
tempMin: 14,
|
||||
humidity: 40,
|
||||
precipitation: 0,
|
||||
wind: 10,
|
||||
impact: 'high-demand',
|
||||
recommendation: 'Incrementar productos frescos y ensaladas'
|
||||
}
|
||||
];
|
||||
|
||||
const weatherImpacts = [
|
||||
{
|
||||
condition: 'Día Soleado',
|
||||
icon: Sun,
|
||||
impact: 'Aumento del 25% en bebidas frías',
|
||||
recommendations: [
|
||||
'Incrementar producción de helados',
|
||||
'Más bebidas refrescantes',
|
||||
'Ensaladas y productos frescos',
|
||||
'Horario extendido de terraza'
|
||||
],
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
condition: 'Día Lluvioso',
|
||||
icon: CloudRain,
|
||||
impact: 'Aumento del 40% en productos calientes',
|
||||
recommendations: [
|
||||
'Más sopas y caldos',
|
||||
'Chocolates calientes',
|
||||
'Pan recién horneado',
|
||||
'Productos de repostería'
|
||||
],
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
condition: 'Frío Intenso',
|
||||
icon: Thermometer,
|
||||
impact: 'Preferencia por comida reconfortante',
|
||||
recommendations: [
|
||||
'Aumentar productos horneados',
|
||||
'Bebidas calientes especiales',
|
||||
'Productos energéticos',
|
||||
'Promociones de interior'
|
||||
],
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
|
||||
const seasonalTrends = [
|
||||
{
|
||||
season: 'Primavera',
|
||||
period: 'Mar - May',
|
||||
trends: [
|
||||
'Aumento en productos frescos (+30%)',
|
||||
'Mayor demanda de ensaladas',
|
||||
'Bebidas naturales populares',
|
||||
'Horarios extendidos efectivos'
|
||||
],
|
||||
avgTemp: '15-20°C',
|
||||
impact: 'positive'
|
||||
},
|
||||
{
|
||||
season: 'Verano',
|
||||
period: 'Jun - Ago',
|
||||
trends: [
|
||||
'Pico de helados y granizados (+60%)',
|
||||
'Productos ligeros preferidos',
|
||||
'Horario matutino crítico',
|
||||
'Mayor tráfico de turistas'
|
||||
],
|
||||
avgTemp: '25-35°C',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
season: 'Otoño',
|
||||
period: 'Sep - Nov',
|
||||
trends: [
|
||||
'Regreso a productos tradicionales',
|
||||
'Aumento en bollería (+20%)',
|
||||
'Bebidas calientes populares',
|
||||
'Horarios regulares'
|
||||
],
|
||||
avgTemp: '10-18°C',
|
||||
impact: 'stable'
|
||||
},
|
||||
{
|
||||
season: 'Invierno',
|
||||
period: 'Dec - Feb',
|
||||
trends: [
|
||||
'Máximo de productos calientes (+50%)',
|
||||
'Pan recién horneado crítico',
|
||||
'Chocolates y dulces festivos',
|
||||
'Menor tráfico general (-15%)'
|
||||
],
|
||||
avgTemp: '5-12°C',
|
||||
impact: 'comfort'
|
||||
}
|
||||
];
|
||||
|
||||
const getWeatherIcon = (condition: string) => {
|
||||
const iconProps = { className: "w-8 h-8" };
|
||||
switch (condition) {
|
||||
case 'sunny': return <Sun {...iconProps} className="w-8 h-8 text-yellow-500" />;
|
||||
case 'partly-cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-[var(--text-tertiary)]" />;
|
||||
case 'cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-[var(--text-secondary)]" />;
|
||||
case 'rainy': return <CloudRain {...iconProps} className="w-8 h-8 text-blue-500" />;
|
||||
default: return <Cloud {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionLabel = (condition: string) => {
|
||||
switch (condition) {
|
||||
case 'sunny': return 'Soleado';
|
||||
case 'partly-cloudy': return 'Parcialmente nublado';
|
||||
case 'cloudy': return 'Nublado';
|
||||
case 'rainy': return 'Lluvioso';
|
||||
default: return condition;
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'green';
|
||||
case 'comfort-food': return 'orange';
|
||||
case 'moderate': return 'blue';
|
||||
case 'normal': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactLabel = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'Alta Demanda';
|
||||
case 'comfort-food': return 'Comida Reconfortante';
|
||||
case 'moderate': return 'Demanda Moderada';
|
||||
case 'normal': return 'Demanda Normal';
|
||||
default: return impact;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Datos Meteorológicos"
|
||||
description="Integra información del clima para optimizar la producción y ventas"
|
||||
/>
|
||||
|
||||
{/* Current Weather */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Condiciones Actuales</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{getWeatherIcon(currentWeather.condition)}
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{currentWeather.temperature}°C</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{currentWeather.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Droplets className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Humedad: {currentWeather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wind className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Viento: {currentWeather.windSpeed} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Presión:</span> {currentWeather.pressure} hPa
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">UV:</span> {currentWeather.uvIndex}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Visibilidad:</span> {currentWeather.visibility} km
|
||||
</div>
|
||||
<Badge variant="blue">Condiciones favorables</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Forecast */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Pronóstico Extendido</h3>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
>
|
||||
<option value="week">Próxima Semana</option>
|
||||
<option value="month">Próximo Mes</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{forecast.map((day, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="text-center mb-3">
|
||||
<p className="font-medium text-[var(--text-primary)]">{day.day}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{new Date(day.date).toLocaleDateString('es-ES')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-3">
|
||||
{getWeatherIcon(day.condition)}
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-[var(--text-secondary)]">{getConditionLabel(day.condition)}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{day.tempMax}° <span className="text-sm text-[var(--text-tertiary)]">/ {day.tempMin}°</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>Humedad:</span>
|
||||
<span>{day.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Lluvia:</span>
|
||||
<span>{day.precipitation}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Viento:</span>
|
||||
<span>{day.wind} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Badge variant={getImpactColor(day.impact)} className="text-xs">
|
||||
{getImpactLabel(day.impact)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-[var(--text-secondary)]">{day.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Impact Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto del Clima</h3>
|
||||
<div className="space-y-4">
|
||||
{weatherImpacts.map((impact, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg bg-${impact.color}-100`}>
|
||||
<impact.icon className={`w-5 h-5 text-${impact.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{impact.condition}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{impact.impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-10">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)] mb-2">Recomendaciones:</p>
|
||||
<ul className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
{impact.recommendations.map((rec, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full mr-2"></span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Trends */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Tendencias Estacionales</h3>
|
||||
<div className="space-y-4">
|
||||
{seasonalTrends.map((season, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{season.season}</h4>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">{season.period}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">{season.avgTemp}</p>
|
||||
<Badge variant={
|
||||
season.impact === 'high' ? 'green' :
|
||||
season.impact === 'positive' ? 'blue' :
|
||||
season.impact === 'comfort' ? 'orange' : 'gray'
|
||||
}>
|
||||
{season.impact === 'high' ? 'Alto' :
|
||||
season.impact === 'positive' ? 'Positivo' :
|
||||
season.impact === 'comfort' ? 'Confort' : 'Estable'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
{season.trends.map((trend, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<TrendingUp className="w-3 h-3 mr-2 text-green-500" />
|
||||
{trend}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Weather Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas Meteorológicas</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Sun className="w-5 h-5 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800">Ola de calor prevista</p>
|
||||
<p className="text-sm text-yellow-700">Se esperan temperaturas superiores a 30°C los próximos 3 días</p>
|
||||
<p className="text-xs text-yellow-600 mt-1">Recomendación: Incrementar stock de bebidas frías y helados</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-3 bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 rounded-lg">
|
||||
<CloudRain className="w-5 h-5 text-[var(--color-info)] mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-info)]">Lluvia intensa el lunes</p>
|
||||
<p className="text-sm text-[var(--color-info)]">80% probabilidad de precipitación con vientos fuertes</p>
|
||||
<p className="text-xs text-[var(--color-info)] mt-1">Recomendación: Preparar más productos calientes y de refugio</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherPage;
|
||||
423
frontend/src/pages/app/data/weather/WeatherPage.tsx.backup
Normal file
423
frontend/src/pages/app/data/weather/WeatherPage.tsx.backup
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Cloud, Sun, CloudRain, Thermometer, Wind, Droplets, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const WeatherPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
|
||||
const currentWeather = {
|
||||
temperature: 18,
|
||||
condition: 'partly-cloudy',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
pressure: 1013,
|
||||
uvIndex: 4,
|
||||
visibility: 10,
|
||||
description: 'Parcialmente nublado'
|
||||
};
|
||||
|
||||
const forecast = [
|
||||
{
|
||||
date: '2024-01-27',
|
||||
day: 'Sábado',
|
||||
condition: 'sunny',
|
||||
tempMax: 22,
|
||||
tempMin: 12,
|
||||
humidity: 45,
|
||||
precipitation: 0,
|
||||
wind: 8,
|
||||
impact: 'high-demand',
|
||||
recommendation: 'Incrementar producción de helados y bebidas frías'
|
||||
},
|
||||
{
|
||||
date: '2024-01-28',
|
||||
day: 'Domingo',
|
||||
condition: 'partly-cloudy',
|
||||
tempMax: 19,
|
||||
tempMin: 11,
|
||||
humidity: 55,
|
||||
precipitation: 20,
|
||||
wind: 15,
|
||||
impact: 'normal',
|
||||
recommendation: 'Producción estándar'
|
||||
},
|
||||
{
|
||||
date: '2024-01-29',
|
||||
day: 'Lunes',
|
||||
condition: 'rainy',
|
||||
tempMax: 15,
|
||||
tempMin: 8,
|
||||
humidity: 85,
|
||||
precipitation: 80,
|
||||
wind: 22,
|
||||
impact: 'comfort-food',
|
||||
recommendation: 'Aumentar sopas, chocolates calientes y pan recién horneado'
|
||||
},
|
||||
{
|
||||
date: '2024-01-30',
|
||||
day: 'Martes',
|
||||
condition: 'cloudy',
|
||||
tempMax: 16,
|
||||
tempMin: 9,
|
||||
humidity: 70,
|
||||
precipitation: 40,
|
||||
wind: 18,
|
||||
impact: 'moderate',
|
||||
recommendation: 'Enfoque en productos de interior'
|
||||
},
|
||||
{
|
||||
date: '2024-01-31',
|
||||
day: 'Miércoles',
|
||||
condition: 'sunny',
|
||||
tempMax: 24,
|
||||
tempMin: 14,
|
||||
humidity: 40,
|
||||
precipitation: 0,
|
||||
wind: 10,
|
||||
impact: 'high-demand',
|
||||
recommendation: 'Incrementar productos frescos y ensaladas'
|
||||
}
|
||||
];
|
||||
|
||||
const weatherImpacts = [
|
||||
{
|
||||
condition: 'Día Soleado',
|
||||
icon: Sun,
|
||||
impact: 'Aumento del 25% en bebidas frías',
|
||||
recommendations: [
|
||||
'Incrementar producción de helados',
|
||||
'Más bebidas refrescantes',
|
||||
'Ensaladas y productos frescos',
|
||||
'Horario extendido de terraza'
|
||||
],
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
condition: 'Día Lluvioso',
|
||||
icon: CloudRain,
|
||||
impact: 'Aumento del 40% en productos calientes',
|
||||
recommendations: [
|
||||
'Más sopas y caldos',
|
||||
'Chocolates calientes',
|
||||
'Pan recién horneado',
|
||||
'Productos de repostería'
|
||||
],
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
condition: 'Frío Intenso',
|
||||
icon: Thermometer,
|
||||
impact: 'Preferencia por comida reconfortante',
|
||||
recommendations: [
|
||||
'Aumentar productos horneados',
|
||||
'Bebidas calientes especiales',
|
||||
'Productos energéticos',
|
||||
'Promociones de interior'
|
||||
],
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
|
||||
const seasonalTrends = [
|
||||
{
|
||||
season: 'Primavera',
|
||||
period: 'Mar - May',
|
||||
trends: [
|
||||
'Aumento en productos frescos (+30%)',
|
||||
'Mayor demanda de ensaladas',
|
||||
'Bebidas naturales populares',
|
||||
'Horarios extendidos efectivos'
|
||||
],
|
||||
avgTemp: '15-20°C',
|
||||
impact: 'positive'
|
||||
},
|
||||
{
|
||||
season: 'Verano',
|
||||
period: 'Jun - Ago',
|
||||
trends: [
|
||||
'Pico de helados y granizados (+60%)',
|
||||
'Productos ligeros preferidos',
|
||||
'Horario matutino crítico',
|
||||
'Mayor tráfico de turistas'
|
||||
],
|
||||
avgTemp: '25-35°C',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
season: 'Otoño',
|
||||
period: 'Sep - Nov',
|
||||
trends: [
|
||||
'Regreso a productos tradicionales',
|
||||
'Aumento en bollería (+20%)',
|
||||
'Bebidas calientes populares',
|
||||
'Horarios regulares'
|
||||
],
|
||||
avgTemp: '10-18°C',
|
||||
impact: 'stable'
|
||||
},
|
||||
{
|
||||
season: 'Invierno',
|
||||
period: 'Dec - Feb',
|
||||
trends: [
|
||||
'Máximo de productos calientes (+50%)',
|
||||
'Pan recién horneado crítico',
|
||||
'Chocolates y dulces festivos',
|
||||
'Menor tráfico general (-15%)'
|
||||
],
|
||||
avgTemp: '5-12°C',
|
||||
impact: 'comfort'
|
||||
}
|
||||
];
|
||||
|
||||
const getWeatherIcon = (condition: string) => {
|
||||
const iconProps = { className: "w-8 h-8" };
|
||||
switch (condition) {
|
||||
case 'sunny': return <Sun {...iconProps} className="w-8 h-8 text-yellow-500" />;
|
||||
case 'partly-cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-gray-400" />;
|
||||
case 'cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-gray-600" />;
|
||||
case 'rainy': return <CloudRain {...iconProps} className="w-8 h-8 text-blue-500" />;
|
||||
default: return <Cloud {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionLabel = (condition: string) => {
|
||||
switch (condition) {
|
||||
case 'sunny': return 'Soleado';
|
||||
case 'partly-cloudy': return 'Parcialmente nublado';
|
||||
case 'cloudy': return 'Nublado';
|
||||
case 'rainy': return 'Lluvioso';
|
||||
default: return condition;
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'green';
|
||||
case 'comfort-food': return 'orange';
|
||||
case 'moderate': return 'blue';
|
||||
case 'normal': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactLabel = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'Alta Demanda';
|
||||
case 'comfort-food': return 'Comida Reconfortante';
|
||||
case 'moderate': return 'Demanda Moderada';
|
||||
case 'normal': return 'Demanda Normal';
|
||||
default: return impact;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Datos Meteorológicos"
|
||||
description="Integra información del clima para optimizar la producción y ventas"
|
||||
/>
|
||||
|
||||
{/* Current Weather */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Condiciones Actuales</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{getWeatherIcon(currentWeather.condition)}
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-gray-900">{currentWeather.temperature}°C</p>
|
||||
<p className="text-sm text-gray-600">{currentWeather.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Droplets className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm text-gray-600">Humedad: {currentWeather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wind className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-600">Viento: {currentWeather.windSpeed} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Presión:</span> {currentWeather.pressure} hPa
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">UV:</span> {currentWeather.uvIndex}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Visibilidad:</span> {currentWeather.visibility} km
|
||||
</div>
|
||||
<Badge variant="blue">Condiciones favorables</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Forecast */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Pronóstico Extendido</h3>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="week">Próxima Semana</option>
|
||||
<option value="month">Próximo Mes</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{forecast.map((day, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="text-center mb-3">
|
||||
<p className="font-medium text-gray-900">{day.day}</p>
|
||||
<p className="text-xs text-gray-500">{new Date(day.date).toLocaleDateString('es-ES')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-3">
|
||||
{getWeatherIcon(day.condition)}
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-gray-600">{getConditionLabel(day.condition)}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{day.tempMax}° <span className="text-sm text-gray-500">/ {day.tempMin}°</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Humedad:</span>
|
||||
<span>{day.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Lluvia:</span>
|
||||
<span>{day.precipitation}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Viento:</span>
|
||||
<span>{day.wind} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Badge variant={getImpactColor(day.impact)} className="text-xs">
|
||||
{getImpactLabel(day.impact)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-600">{day.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Impact Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Impacto del Clima</h3>
|
||||
<div className="space-y-4">
|
||||
{weatherImpacts.map((impact, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg bg-${impact.color}-100`}>
|
||||
<impact.icon className={`w-5 h-5 text-${impact.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{impact.condition}</h4>
|
||||
<p className="text-sm text-gray-600">{impact.impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-10">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Recomendaciones:</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{impact.recommendations.map((rec, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full mr-2"></span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Trends */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tendencias Estacionales</h3>
|
||||
<div className="space-y-4">
|
||||
{seasonalTrends.map((season, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{season.season}</h4>
|
||||
<p className="text-sm text-gray-500">{season.period}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-700">{season.avgTemp}</p>
|
||||
<Badge variant={
|
||||
season.impact === 'high' ? 'green' :
|
||||
season.impact === 'positive' ? 'blue' :
|
||||
season.impact === 'comfort' ? 'orange' : 'gray'
|
||||
}>
|
||||
{season.impact === 'high' ? 'Alto' :
|
||||
season.impact === 'positive' ? 'Positivo' :
|
||||
season.impact === 'comfort' ? 'Confort' : 'Estable'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{season.trends.map((trend, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<TrendingUp className="w-3 h-3 mr-2 text-green-500" />
|
||||
{trend}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Weather Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alertas Meteorológicas</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Sun className="w-5 h-5 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800">Ola de calor prevista</p>
|
||||
<p className="text-sm text-yellow-700">Se esperan temperaturas superiores a 30°C los próximos 3 días</p>
|
||||
<p className="text-xs text-yellow-600 mt-1">Recomendación: Incrementar stock de bebidas frías y helados</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<CloudRain className="w-5 h-5 text-blue-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Lluvia intensa el lunes</p>
|
||||
<p className="text-sm text-blue-700">80% probabilidad de precipitación con vientos fuertes</p>
|
||||
<p className="text-xs text-blue-600 mt-1">Recomendación: Preparar más productos calientes y de refugio</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherPage;
|
||||
1
frontend/src/pages/app/data/weather/index.ts
Normal file
1
frontend/src/pages/app/data/weather/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as WeatherPage } from './WeatherPage';
|
||||
@@ -0,0 +1,435 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BarChart3, TrendingUp, Target, AlertCircle, CheckCircle, Eye, Download } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingAnalysisPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('30days');
|
||||
|
||||
const analysisData = {
|
||||
onboardingScore: 87,
|
||||
completionRate: 92,
|
||||
averageTime: '4.2 días',
|
||||
stepsCompleted: 15,
|
||||
totalSteps: 16,
|
||||
dataQuality: 94
|
||||
};
|
||||
|
||||
const stepProgress = [
|
||||
{ step: 'Información Básica', completed: true, quality: 95, timeSpent: '25 min' },
|
||||
{ step: 'Configuración de Menú', completed: true, quality: 88, timeSpent: '1.2 horas' },
|
||||
{ step: 'Datos de Inventario', completed: true, quality: 92, timeSpent: '45 min' },
|
||||
{ step: 'Configuración de Horarios', completed: true, quality: 100, timeSpent: '15 min' },
|
||||
{ step: 'Integración de Pagos', completed: true, quality: 85, timeSpent: '30 min' },
|
||||
{ step: 'Carga de Productos', completed: true, quality: 90, timeSpent: '2.1 horas' },
|
||||
{ step: 'Configuración de Personal', completed: true, quality: 87, timeSpent: '40 min' },
|
||||
{ step: 'Pruebas del Sistema', completed: false, quality: 0, timeSpent: '-' }
|
||||
];
|
||||
|
||||
const insights = [
|
||||
{
|
||||
type: 'success',
|
||||
title: 'Excelente Progreso',
|
||||
description: 'Has completado el 94% del proceso de configuración inicial',
|
||||
recommendation: 'Solo faltan las pruebas finales del sistema para completar tu configuración',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
title: 'Calidad de Datos Alta',
|
||||
description: 'Tus datos tienen una calidad promedio del 94%',
|
||||
recommendation: 'Considera revisar la configuración de pagos para mejorar la puntuación',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
title: 'Paso Pendiente',
|
||||
description: 'Las pruebas del sistema están pendientes',
|
||||
recommendation: 'Programa las pruebas para validar la configuración completa',
|
||||
impact: 'high'
|
||||
}
|
||||
];
|
||||
|
||||
const dataAnalysis = [
|
||||
{
|
||||
category: 'Información del Negocio',
|
||||
completeness: 100,
|
||||
accuracy: 95,
|
||||
items: 12,
|
||||
issues: 0,
|
||||
details: 'Toda la información básica está completa y verificada'
|
||||
},
|
||||
{
|
||||
category: 'Menú y Productos',
|
||||
completeness: 85,
|
||||
accuracy: 88,
|
||||
items: 45,
|
||||
issues: 3,
|
||||
details: '3 productos sin precios definidos'
|
||||
},
|
||||
{
|
||||
category: 'Inventario Inicial',
|
||||
completeness: 92,
|
||||
accuracy: 90,
|
||||
items: 28,
|
||||
issues: 2,
|
||||
details: '2 ingredientes sin stock mínimo definido'
|
||||
},
|
||||
{
|
||||
category: 'Configuración Operativa',
|
||||
completeness: 100,
|
||||
accuracy: 100,
|
||||
items: 8,
|
||||
issues: 0,
|
||||
details: 'Horarios y políticas completamente configuradas'
|
||||
}
|
||||
];
|
||||
|
||||
const benchmarkComparison = {
|
||||
industry: {
|
||||
onboardingScore: 74,
|
||||
completionRate: 78,
|
||||
averageTime: '6.8 días'
|
||||
},
|
||||
yourData: {
|
||||
onboardingScore: 87,
|
||||
completionRate: 92,
|
||||
averageTime: '4.2 días'
|
||||
}
|
||||
};
|
||||
|
||||
const recommendations = [
|
||||
{
|
||||
priority: 'high',
|
||||
title: 'Completar Pruebas del Sistema',
|
||||
description: 'Realizar pruebas integrales para validar toda la configuración',
|
||||
estimatedTime: '30 minutos',
|
||||
impact: 'Garantiza funcionamiento óptimo del sistema'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
title: 'Revisar Precios de Productos',
|
||||
description: 'Definir precios para los 3 productos pendientes',
|
||||
estimatedTime: '15 minutos',
|
||||
impact: 'Permitirá generar ventas de todos los productos'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
title: 'Configurar Stocks Mínimos',
|
||||
description: 'Establecer niveles mínimos para 2 ingredientes',
|
||||
estimatedTime: '10 minutos',
|
||||
impact: 'Mejorará el control de inventario automático'
|
||||
},
|
||||
{
|
||||
priority: 'low',
|
||||
title: 'Optimizar Configuración de Pagos',
|
||||
description: 'Revisar métodos de pago y comisiones',
|
||||
estimatedTime: '20 minutos',
|
||||
impact: 'Puede reducir costos de transacción'
|
||||
}
|
||||
];
|
||||
|
||||
const getInsightIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle {...iconProps} className="w-5 h-5 text-[var(--color-success)]" />;
|
||||
case 'warning': return <AlertCircle {...iconProps} className="w-5 h-5 text-yellow-600" />;
|
||||
case 'info': return <Target {...iconProps} className="w-5 h-5 text-[var(--color-info)]" />;
|
||||
default: return <AlertCircle {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getInsightColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success': return 'bg-green-50 border-green-200';
|
||||
case 'warning': return 'bg-yellow-50 border-yellow-200';
|
||||
case 'info': return 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20';
|
||||
default: return 'bg-[var(--bg-secondary)] border-[var(--border-primary)]';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getCompletionColor = (percentage: number) => {
|
||||
if (percentage >= 95) return 'text-[var(--color-success)]';
|
||||
if (percentage >= 80) return 'text-yellow-600';
|
||||
return 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Configuración"
|
||||
description="Análisis detallado de tu proceso de configuración y recomendaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Overall Score */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-[var(--color-success)]">{analysisData.onboardingScore}</span>
|
||||
</div>
|
||||
<svg className="w-24 h-24 transform -rotate-90">
|
||||
<circle
|
||||
cx="48"
|
||||
cy="48"
|
||||
r="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="48"
|
||||
cy="48"
|
||||
r="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${analysisData.onboardingScore * 2.51} 251`}
|
||||
className="text-[var(--color-success)]"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Puntuación General</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{analysisData.completionRate}%</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completado</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-purple-600">{analysisData.averageTime}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Tiempo Promedio</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{analysisData.stepsCompleted}/{analysisData.totalSteps}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Pasos Completados</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-teal-600">{analysisData.dataQuality}%</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Calidad de Datos</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<TrendingUp className="w-8 h-8 text-[var(--color-success)]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Por encima del promedio</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Progress Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Progreso por Pasos</h3>
|
||||
<div className="space-y-4">
|
||||
{stepProgress.map((step, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
step.completed ? 'bg-[var(--color-success)]/10' : 'bg-[var(--bg-tertiary)]'
|
||||
}`}>
|
||||
{step.completed ? (
|
||||
<CheckCircle className="w-5 h-5 text-[var(--color-success)]" />
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[var(--text-tertiary)]">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{step.step}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Tiempo: {step.timeSpent}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${getCompletionColor(step.quality)}`}>
|
||||
{step.quality}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Calidad</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Comparación con la Industria</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Puntuación de Configuración</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-[var(--color-success)]">{benchmarkComparison.yourData.onboardingScore}</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-2">vs {benchmarkComparison.industry.onboardingScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${(benchmarkComparison.yourData.onboardingScore / 100) * 100}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Tasa de Completado</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-[var(--color-info)]">{benchmarkComparison.yourData.completionRate}%</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-2">vs {benchmarkComparison.industry.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${benchmarkComparison.yourData.completionRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Tiempo de Configuración</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-purple-600">{benchmarkComparison.yourData.averageTime}</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-2">vs {benchmarkComparison.industry.averageTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-[var(--color-success)]">38% más rápido que el promedio de la industria</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Insights y Recomendaciones</h3>
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight, index) => (
|
||||
<div key={index} className={`p-4 rounded-lg border ${getInsightColor(insight.type)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getInsightIcon(insight.type)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-1">{insight.title}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{insight.description}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Recomendación: {insight.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={insight.impact === 'high' ? 'red' : insight.impact === 'medium' ? 'yellow' : 'green'}>
|
||||
{insight.impact === 'high' ? 'Alto' : insight.impact === 'medium' ? 'Medio' : 'Bajo'} Impacto
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Analysis */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Análisis de Calidad de Datos</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{dataAnalysis.map((category, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{category.category}</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<BarChart3 className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{category.items} elementos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Completitud</span>
|
||||
<span className={getCompletionColor(category.completeness)}>{category.completeness}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${category.completeness}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Precisión</span>
|
||||
<span className={getCompletionColor(category.accuracy)}>{category.accuracy}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${category.accuracy}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{category.issues > 0 && (
|
||||
<div className="flex items-center text-sm text-[var(--color-error)]">
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
<span>{category.issues} problema{category.issues > 1 ? 's' : ''} detectado{category.issues > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)]">{category.details}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action Items */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Elementos de Acción</h3>
|
||||
<div className="space-y-3">
|
||||
{recommendations.map((rec, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<Badge variant={getPriorityColor(rec.priority)}>
|
||||
{rec.priority === 'high' ? 'Alta' : rec.priority === 'medium' ? 'Media' : 'Baja'}
|
||||
</Badge>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-1">{rec.title}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-1">{rec.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>Tiempo estimado: {rec.estimatedTime}</span>
|
||||
<span>•</span>
|
||||
<span>Impacto: {rec.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm">
|
||||
Completar
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingAnalysisPage;
|
||||
@@ -0,0 +1,435 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BarChart3, TrendingUp, Target, AlertCircle, CheckCircle, Eye, Download } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingAnalysisPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('30days');
|
||||
|
||||
const analysisData = {
|
||||
onboardingScore: 87,
|
||||
completionRate: 92,
|
||||
averageTime: '4.2 días',
|
||||
stepsCompleted: 15,
|
||||
totalSteps: 16,
|
||||
dataQuality: 94
|
||||
};
|
||||
|
||||
const stepProgress = [
|
||||
{ step: 'Información Básica', completed: true, quality: 95, timeSpent: '25 min' },
|
||||
{ step: 'Configuración de Menú', completed: true, quality: 88, timeSpent: '1.2 horas' },
|
||||
{ step: 'Datos de Inventario', completed: true, quality: 92, timeSpent: '45 min' },
|
||||
{ step: 'Configuración de Horarios', completed: true, quality: 100, timeSpent: '15 min' },
|
||||
{ step: 'Integración de Pagos', completed: true, quality: 85, timeSpent: '30 min' },
|
||||
{ step: 'Carga de Productos', completed: true, quality: 90, timeSpent: '2.1 horas' },
|
||||
{ step: 'Configuración de Personal', completed: true, quality: 87, timeSpent: '40 min' },
|
||||
{ step: 'Pruebas del Sistema', completed: false, quality: 0, timeSpent: '-' }
|
||||
];
|
||||
|
||||
const insights = [
|
||||
{
|
||||
type: 'success',
|
||||
title: 'Excelente Progreso',
|
||||
description: 'Has completado el 94% del proceso de configuración inicial',
|
||||
recommendation: 'Solo faltan las pruebas finales del sistema para completar tu configuración',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
title: 'Calidad de Datos Alta',
|
||||
description: 'Tus datos tienen una calidad promedio del 94%',
|
||||
recommendation: 'Considera revisar la configuración de pagos para mejorar la puntuación',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
title: 'Paso Pendiente',
|
||||
description: 'Las pruebas del sistema están pendientes',
|
||||
recommendation: 'Programa las pruebas para validar la configuración completa',
|
||||
impact: 'high'
|
||||
}
|
||||
];
|
||||
|
||||
const dataAnalysis = [
|
||||
{
|
||||
category: 'Información del Negocio',
|
||||
completeness: 100,
|
||||
accuracy: 95,
|
||||
items: 12,
|
||||
issues: 0,
|
||||
details: 'Toda la información básica está completa y verificada'
|
||||
},
|
||||
{
|
||||
category: 'Menú y Productos',
|
||||
completeness: 85,
|
||||
accuracy: 88,
|
||||
items: 45,
|
||||
issues: 3,
|
||||
details: '3 productos sin precios definidos'
|
||||
},
|
||||
{
|
||||
category: 'Inventario Inicial',
|
||||
completeness: 92,
|
||||
accuracy: 90,
|
||||
items: 28,
|
||||
issues: 2,
|
||||
details: '2 ingredientes sin stock mínimo definido'
|
||||
},
|
||||
{
|
||||
category: 'Configuración Operativa',
|
||||
completeness: 100,
|
||||
accuracy: 100,
|
||||
items: 8,
|
||||
issues: 0,
|
||||
details: 'Horarios y políticas completamente configuradas'
|
||||
}
|
||||
];
|
||||
|
||||
const benchmarkComparison = {
|
||||
industry: {
|
||||
onboardingScore: 74,
|
||||
completionRate: 78,
|
||||
averageTime: '6.8 días'
|
||||
},
|
||||
yourData: {
|
||||
onboardingScore: 87,
|
||||
completionRate: 92,
|
||||
averageTime: '4.2 días'
|
||||
}
|
||||
};
|
||||
|
||||
const recommendations = [
|
||||
{
|
||||
priority: 'high',
|
||||
title: 'Completar Pruebas del Sistema',
|
||||
description: 'Realizar pruebas integrales para validar toda la configuración',
|
||||
estimatedTime: '30 minutos',
|
||||
impact: 'Garantiza funcionamiento óptimo del sistema'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
title: 'Revisar Precios de Productos',
|
||||
description: 'Definir precios para los 3 productos pendientes',
|
||||
estimatedTime: '15 minutos',
|
||||
impact: 'Permitirá generar ventas de todos los productos'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
title: 'Configurar Stocks Mínimos',
|
||||
description: 'Establecer niveles mínimos para 2 ingredientes',
|
||||
estimatedTime: '10 minutos',
|
||||
impact: 'Mejorará el control de inventario automático'
|
||||
},
|
||||
{
|
||||
priority: 'low',
|
||||
title: 'Optimizar Configuración de Pagos',
|
||||
description: 'Revisar métodos de pago y comisiones',
|
||||
estimatedTime: '20 minutos',
|
||||
impact: 'Puede reducir costos de transacción'
|
||||
}
|
||||
];
|
||||
|
||||
const getInsightIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle {...iconProps} className="w-5 h-5 text-green-600" />;
|
||||
case 'warning': return <AlertCircle {...iconProps} className="w-5 h-5 text-yellow-600" />;
|
||||
case 'info': return <Target {...iconProps} className="w-5 h-5 text-blue-600" />;
|
||||
default: return <AlertCircle {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getInsightColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success': return 'bg-green-50 border-green-200';
|
||||
case 'warning': return 'bg-yellow-50 border-yellow-200';
|
||||
case 'info': return 'bg-blue-50 border-blue-200';
|
||||
default: return 'bg-gray-50 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getCompletionColor = (percentage: number) => {
|
||||
if (percentage >= 95) return 'text-green-600';
|
||||
if (percentage >= 80) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Configuración"
|
||||
description="Análisis detallado de tu proceso de configuración y recomendaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Overall Score */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-green-600">{analysisData.onboardingScore}</span>
|
||||
</div>
|
||||
<svg className="w-24 h-24 transform -rotate-90">
|
||||
<circle
|
||||
cx="48"
|
||||
cy="48"
|
||||
r="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="48"
|
||||
cy="48"
|
||||
r="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${analysisData.onboardingScore * 2.51} 251`}
|
||||
className="text-green-600"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">Puntuación General</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-600">{analysisData.completionRate}%</p>
|
||||
<p className="text-sm font-medium text-gray-700">Completado</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-purple-600">{analysisData.averageTime}</p>
|
||||
<p className="text-sm font-medium text-gray-700">Tiempo Promedio</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-orange-600">{analysisData.stepsCompleted}/{analysisData.totalSteps}</p>
|
||||
<p className="text-sm font-medium text-gray-700">Pasos Completados</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-teal-600">{analysisData.dataQuality}%</p>
|
||||
<p className="text-sm font-medium text-gray-700">Calidad de Datos</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">Por encima del promedio</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Progress Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Progreso por Pasos</h3>
|
||||
<div className="space-y-4">
|
||||
{stepProgress.map((step, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
step.completed ? 'bg-green-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
{step.completed ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<span className="text-sm font-medium text-gray-500">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{step.step}</p>
|
||||
<p className="text-xs text-gray-500">Tiempo: {step.timeSpent}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${getCompletionColor(step.quality)}`}>
|
||||
{step.quality}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Calidad</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Comparación con la Industria</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Puntuación de Configuración</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-green-600">{benchmarkComparison.yourData.onboardingScore}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">vs {benchmarkComparison.industry.onboardingScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${(benchmarkComparison.yourData.onboardingScore / 100) * 100}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Tasa de Completado</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-blue-600">{benchmarkComparison.yourData.completionRate}%</span>
|
||||
<span className="text-sm text-gray-500 ml-2">vs {benchmarkComparison.industry.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${benchmarkComparison.yourData.completionRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Tiempo de Configuración</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-purple-600">{benchmarkComparison.yourData.averageTime}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">vs {benchmarkComparison.industry.averageTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-green-700">38% más rápido que el promedio de la industria</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Insights y Recomendaciones</h3>
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight, index) => (
|
||||
<div key={index} className={`p-4 rounded-lg border ${getInsightColor(insight.type)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getInsightIcon(insight.type)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">{insight.title}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{insight.description}</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Recomendación: {insight.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={insight.impact === 'high' ? 'red' : insight.impact === 'medium' ? 'yellow' : 'green'}>
|
||||
{insight.impact === 'high' ? 'Alto' : insight.impact === 'medium' ? 'Medio' : 'Bajo'} Impacto
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Analysis */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Análisis de Calidad de Datos</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{dataAnalysis.map((category, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900">{category.category}</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<BarChart3 className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-500">{category.items} elementos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Completitud</span>
|
||||
<span className={getCompletionColor(category.completeness)}>{category.completeness}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${category.completeness}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Precisión</span>
|
||||
<span className={getCompletionColor(category.accuracy)}>{category.accuracy}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${category.accuracy}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{category.issues > 0 && (
|
||||
<div className="flex items-center text-sm text-red-600">
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
<span>{category.issues} problema{category.issues > 1 ? 's' : ''} detectado{category.issues > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-600">{category.details}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action Items */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Elementos de Acción</h3>
|
||||
<div className="space-y-3">
|
||||
{recommendations.map((rec, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<Badge variant={getPriorityColor(rec.priority)}>
|
||||
{rec.priority === 'high' ? 'Alta' : rec.priority === 'medium' ? 'Media' : 'Baja'}
|
||||
</Badge>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">{rec.title}</h4>
|
||||
<p className="text-sm text-gray-600 mb-1">{rec.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>Tiempo estimado: {rec.estimatedTime}</span>
|
||||
<span>•</span>
|
||||
<span>Impacto: {rec.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm">
|
||||
Completar
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingAnalysisPage;
|
||||
1
frontend/src/pages/app/onboarding/analysis/index.ts
Normal file
1
frontend/src/pages/app/onboarding/analysis/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OnboardingAnalysisPage } from './OnboardingAnalysisPage';
|
||||
@@ -0,0 +1,579 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircle, AlertCircle, Edit2, Eye, Star, ArrowRight, Clock, Users, Zap } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingReviewPage: React.FC = () => {
|
||||
const [activeSection, setActiveSection] = useState<string>('overview');
|
||||
|
||||
const completionData = {
|
||||
overallProgress: 95,
|
||||
totalSteps: 8,
|
||||
completedSteps: 7,
|
||||
remainingSteps: 1,
|
||||
estimatedTimeRemaining: '15 minutos',
|
||||
overallScore: 87
|
||||
};
|
||||
|
||||
const sectionReview = [
|
||||
{
|
||||
id: 'business-info',
|
||||
title: 'Información del Negocio',
|
||||
status: 'completed',
|
||||
score: 98,
|
||||
items: [
|
||||
{ field: 'Nombre de la panadería', value: 'Panadería Artesanal El Buen Pan', status: 'complete' },
|
||||
{ field: 'Dirección', value: 'Av. Principal 123, Centro', status: 'complete' },
|
||||
{ field: 'Teléfono', value: '+1 234 567 8900', status: 'complete' },
|
||||
{ field: 'Email', value: 'info@elbuenpan.com', status: 'complete' },
|
||||
{ field: 'Tipo de negocio', value: 'Panadería y Pastelería Artesanal', status: 'complete' },
|
||||
{ field: 'Horario de operación', value: 'L-V: 6:00-20:00, S-D: 7:00-18:00', status: 'complete' }
|
||||
],
|
||||
recommendations: []
|
||||
},
|
||||
{
|
||||
id: 'menu-products',
|
||||
title: 'Menú y Productos',
|
||||
status: 'completed',
|
||||
score: 85,
|
||||
items: [
|
||||
{ field: 'Productos de panadería', value: '24 productos configurados', status: 'complete' },
|
||||
{ field: 'Productos de pastelería', value: '18 productos configurados', status: 'complete' },
|
||||
{ field: 'Bebidas', value: '12 opciones disponibles', status: 'complete' },
|
||||
{ field: 'Precios configurados', value: '51 de 54 productos (94%)', status: 'warning' },
|
||||
{ field: 'Categorías organizadas', value: '6 categorías principales', status: 'complete' },
|
||||
{ field: 'Descripciones', value: '48 de 54 productos (89%)', status: 'warning' }
|
||||
],
|
||||
recommendations: [
|
||||
'Completar precios para 3 productos pendientes',
|
||||
'Añadir descripciones para 6 productos restantes'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
title: 'Inventario Inicial',
|
||||
status: 'completed',
|
||||
score: 92,
|
||||
items: [
|
||||
{ field: 'Materias primas', value: '45 ingredientes registrados', status: 'complete' },
|
||||
{ field: 'Proveedores', value: '8 proveedores configurados', status: 'complete' },
|
||||
{ field: 'Stocks iniciales', value: '43 de 45 ingredientes (96%)', status: 'warning' },
|
||||
{ field: 'Puntos de reorden', value: '40 de 45 ingredientes (89%)', status: 'warning' },
|
||||
{ field: 'Costos unitarios', value: 'Completado al 100%', status: 'complete' }
|
||||
],
|
||||
recommendations: [
|
||||
'Definir stocks iniciales para 2 ingredientes',
|
||||
'Establecer puntos de reorden para 5 ingredientes'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'staff-config',
|
||||
title: 'Configuración de Personal',
|
||||
status: 'completed',
|
||||
score: 90,
|
||||
items: [
|
||||
{ field: 'Empleados registrados', value: '12 miembros del equipo', status: 'complete' },
|
||||
{ field: 'Roles asignados', value: '5 roles diferentes configurados', status: 'complete' },
|
||||
{ field: 'Horarios de trabajo', value: '11 de 12 empleados (92%)', status: 'warning' },
|
||||
{ field: 'Permisos del sistema', value: 'Configurado completamente', status: 'complete' },
|
||||
{ field: 'Datos de contacto', value: 'Completado al 100%', status: 'complete' }
|
||||
],
|
||||
recommendations: [
|
||||
'Completar horario para 1 empleado pendiente'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
title: 'Configuración Operativa',
|
||||
status: 'completed',
|
||||
score: 95,
|
||||
items: [
|
||||
{ field: 'Horarios de operación', value: 'Configurado completamente', status: 'complete' },
|
||||
{ field: 'Métodos de pago', value: '4 métodos activos', status: 'complete' },
|
||||
{ field: 'Políticas de devoluciones', value: 'Definidas y configuradas', status: 'complete' },
|
||||
{ field: 'Configuración de impuestos', value: '18% IVA configurado', status: 'complete' },
|
||||
{ field: 'Zonas de entrega', value: '3 zonas configuradas', status: 'complete' }
|
||||
],
|
||||
recommendations: []
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Integraciones',
|
||||
status: 'completed',
|
||||
score: 88,
|
||||
items: [
|
||||
{ field: 'Sistema de pagos', value: 'Stripe configurado', status: 'complete' },
|
||||
{ field: 'Notificaciones por email', value: 'SMTP configurado', status: 'complete' },
|
||||
{ field: 'Notificaciones SMS', value: 'Twilio configurado', status: 'complete' },
|
||||
{ field: 'Backup automático', value: 'Configurado diariamente', status: 'complete' },
|
||||
{ field: 'APIs externas', value: '2 de 3 configuradas', status: 'warning' }
|
||||
],
|
||||
recommendations: [
|
||||
'Configurar API de delivery restante'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'testing',
|
||||
title: 'Pruebas del Sistema',
|
||||
status: 'pending',
|
||||
score: 0,
|
||||
items: [
|
||||
{ field: 'Prueba de ventas', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Prueba de inventario', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Prueba de reportes', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Prueba de notificaciones', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Validación de integraciones', value: 'Pendiente', status: 'pending' }
|
||||
],
|
||||
recommendations: [
|
||||
'Ejecutar todas las pruebas del sistema antes del lanzamiento'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'training',
|
||||
title: 'Capacitación del Equipo',
|
||||
status: 'completed',
|
||||
score: 82,
|
||||
items: [
|
||||
{ field: 'Capacitación básica', value: '10 de 12 empleados (83%)', status: 'warning' },
|
||||
{ field: 'Manual del usuario', value: 'Entregado y revisado', status: 'complete' },
|
||||
{ field: 'Sesiones prácticas', value: '2 de 3 sesiones completadas', status: 'warning' },
|
||||
{ field: 'Evaluaciones', value: '8 de 10 empleados aprobados', status: 'warning' },
|
||||
{ field: 'Material de referencia', value: 'Disponible en el sistema', status: 'complete' }
|
||||
],
|
||||
recommendations: [
|
||||
'Completar capacitación para 2 empleados pendientes',
|
||||
'Programar tercera sesión práctica',
|
||||
'Realizar evaluaciones pendientes'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const overallRecommendations = [
|
||||
{
|
||||
priority: 'high',
|
||||
category: 'Crítico',
|
||||
title: 'Completar Pruebas del Sistema',
|
||||
description: 'Ejecutar todas las pruebas funcionales antes del lanzamiento',
|
||||
estimatedTime: '30 minutos',
|
||||
impact: 'Garantiza funcionamiento correcto del sistema'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
category: 'Importante',
|
||||
title: 'Finalizar Configuración de Productos',
|
||||
description: 'Completar precios y descripciones pendientes',
|
||||
estimatedTime: '20 minutos',
|
||||
impact: 'Permite ventas completas de todos los productos'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
category: 'Importante',
|
||||
title: 'Completar Capacitación del Personal',
|
||||
description: 'Finalizar entrenamiento para empleados pendientes',
|
||||
estimatedTime: '45 minutos',
|
||||
impact: 'Asegura operación eficiente desde el primer día'
|
||||
},
|
||||
{
|
||||
priority: 'low',
|
||||
category: 'Opcional',
|
||||
title: 'Optimizar Configuración de Inventario',
|
||||
description: 'Definir stocks y puntos de reorden pendientes',
|
||||
estimatedTime: '15 minutos',
|
||||
impact: 'Mejora control automático de inventario'
|
||||
}
|
||||
];
|
||||
|
||||
const launchReadiness = {
|
||||
essential: {
|
||||
completed: 6,
|
||||
total: 7,
|
||||
percentage: 86
|
||||
},
|
||||
recommended: {
|
||||
completed: 8,
|
||||
total: 12,
|
||||
percentage: 67
|
||||
},
|
||||
optional: {
|
||||
completed: 3,
|
||||
total: 6,
|
||||
percentage: 50
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle {...iconProps} className="w-5 h-5 text-[var(--color-success)]" />;
|
||||
case 'warning': return <AlertCircle {...iconProps} className="w-5 h-5 text-yellow-600" />;
|
||||
case 'pending': return <Clock {...iconProps} className="w-5 h-5 text-[var(--text-secondary)]" />;
|
||||
default: return <AlertCircle {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green';
|
||||
case 'warning': return 'yellow';
|
||||
case 'pending': return 'gray';
|
||||
default: return 'red';
|
||||
}
|
||||
};
|
||||
|
||||
const getItemStatusIcon = (status: string) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
switch (status) {
|
||||
case 'complete': return <CheckCircle {...iconProps} className="w-4 h-4 text-[var(--color-success)]" />;
|
||||
case 'warning': return <AlertCircle {...iconProps} className="w-4 h-4 text-yellow-600" />;
|
||||
case 'pending': return <Clock {...iconProps} className="w-4 h-4 text-[var(--text-secondary)]" />;
|
||||
default: return <AlertCircle {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'text-[var(--color-success)]';
|
||||
if (score >= 80) return 'text-yellow-600';
|
||||
if (score >= 70) return 'text-[var(--color-primary)]';
|
||||
return 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Revisión Final de Configuración"
|
||||
description="Verifica todos los aspectos de tu configuración antes del lanzamiento"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Editar Configuración
|
||||
</Button>
|
||||
<Button>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Lanzar Sistema
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Overall Progress */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="relative w-20 h-20 mx-auto mb-3">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(completionData.overallScore)}`}>
|
||||
{completionData.overallScore}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="w-20 h-20 transform -rotate-90">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
strokeDasharray={`${completionData.overallScore * 2.01} 201`}
|
||||
className="text-[var(--color-success)]"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Puntuación General</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{completionData.overallProgress}%</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Progreso Total</p>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${completionData.overallProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-purple-600">
|
||||
{completionData.completedSteps}/{completionData.totalSteps}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Secciones Completadas</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{completionData.estimatedTimeRemaining}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Tiempo Restante</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="border-b border-[var(--border-primary)]">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{['overview', 'sections', 'recommendations', 'readiness'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSection(tab)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeSection === tab
|
||||
? 'border-blue-500 text-[var(--color-info)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tab === 'overview' && 'Resumen General'}
|
||||
{tab === 'sections' && 'Revisión por Secciones'}
|
||||
{tab === 'recommendations' && 'Recomendaciones'}
|
||||
{tab === 'readiness' && 'Preparación para Lanzamiento'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content based on active section */}
|
||||
{activeSection === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Estado por Secciones</h3>
|
||||
<div className="space-y-3">
|
||||
{sectionReview.map((section) => (
|
||||
<div key={section.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(section.status)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{section.title}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{section.recommendations.length > 0
|
||||
? `${section.recommendations.length} recomendación${section.recommendations.length > 1 ? 'es' : ''}`
|
||||
: 'Completado correctamente'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${getScoreColor(section.score)}`}>
|
||||
{section.score}%
|
||||
</p>
|
||||
<Badge variant={getStatusColor(section.status)}>
|
||||
{section.status === 'completed' ? 'Completado' :
|
||||
section.status === 'warning' ? 'Con observaciones' : 'Pendiente'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Próximos Pasos</h3>
|
||||
<div className="space-y-4">
|
||||
{overallRecommendations.slice(0, 3).map((rec, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 border rounded-lg">
|
||||
<Badge variant={getPriorityColor(rec.priority)} className="mt-1">
|
||||
{rec.category}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">{rec.title}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{rec.description}</p>
|
||||
<div className="flex items-center space-x-4 mt-2 text-xs text-[var(--text-tertiary)]">
|
||||
<span>⏱️ {rec.estimatedTime}</span>
|
||||
<span>💡 {rec.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-[var(--text-tertiary)] mt-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-[var(--color-info)]/5 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Star className="w-5 h-5 text-[var(--color-info)]" />
|
||||
<h4 className="font-medium text-blue-900">¡Excelente progreso!</h4>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-info)]">
|
||||
Has completado el 95% de la configuración. Solo quedan algunos detalles finales antes del lanzamiento.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'sections' && (
|
||||
<div className="space-y-6">
|
||||
{sectionReview.map((section) => (
|
||||
<Card key={section.id} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(section.status)}
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{section.title}</h3>
|
||||
<Badge variant={getStatusColor(section.status)}>
|
||||
Puntuación: {section.score}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{section.items.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getItemStatusIcon(item.status)}
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">{item.field}</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{section.recommendations.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-yellow-800 mb-2">Recomendaciones:</h4>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
{section.recommendations.map((rec, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'recommendations' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Todas las Recomendaciones</h3>
|
||||
<div className="space-y-4">
|
||||
{overallRecommendations.map((rec, index) => (
|
||||
<div key={index} className="flex items-start justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<Badge variant={getPriorityColor(rec.priority)}>
|
||||
{rec.category}
|
||||
</Badge>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-1">{rec.title}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{rec.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>⏱️ Tiempo estimado: {rec.estimatedTime}</span>
|
||||
<span>💡 Impacto: {rec.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">Más Información</Button>
|
||||
<Button size="sm">Completar</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'readiness' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Preparación para el Lanzamiento</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="w-16 h-16 mx-auto mb-3 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-[var(--color-success)]" />
|
||||
</div>
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Elementos Esenciales</h4>
|
||||
<p className="text-2xl font-bold text-[var(--color-success)] mb-1">
|
||||
{launchReadiness.essential.completed}/{launchReadiness.essential.total}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{launchReadiness.essential.percentage}% completado</p>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full"
|
||||
style={{ width: `${launchReadiness.essential.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="w-16 h-16 mx-auto mb-3 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<Star className="w-8 h-8 text-yellow-600" />
|
||||
</div>
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Recomendados</h4>
|
||||
<p className="text-2xl font-bold text-yellow-600 mb-1">
|
||||
{launchReadiness.recommended.completed}/{launchReadiness.recommended.total}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{launchReadiness.recommended.percentage}% completado</p>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-yellow-600 h-2 rounded-full"
|
||||
style={{ width: `${launchReadiness.recommended.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="w-16 h-16 mx-auto mb-3 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-[var(--color-info)]" />
|
||||
</div>
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Opcionales</h4>
|
||||
<p className="text-2xl font-bold text-[var(--color-info)] mb-1">
|
||||
{launchReadiness.optional.completed}/{launchReadiness.optional.total}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{launchReadiness.optional.percentage}% completado</p>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${launchReadiness.optional.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<CheckCircle className="w-6 h-6 text-[var(--color-success)]" />
|
||||
<h4 className="text-lg font-semibold text-green-900">¡Listo para Lanzar!</h4>
|
||||
</div>
|
||||
<p className="text-[var(--color-success)] mb-4">
|
||||
Tu configuración está lista para el lanzamiento. Todos los elementos esenciales están completados
|
||||
y el sistema está preparado para comenzar a operar.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Lanzar Ahora
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
Ejecutar Pruebas Finales
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingReviewPage;
|
||||
@@ -0,0 +1,579 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircle, AlertCircle, Edit2, Eye, Star, ArrowRight, Clock, Users, Zap } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingReviewPage: React.FC = () => {
|
||||
const [activeSection, setActiveSection] = useState<string>('overview');
|
||||
|
||||
const completionData = {
|
||||
overallProgress: 95,
|
||||
totalSteps: 8,
|
||||
completedSteps: 7,
|
||||
remainingSteps: 1,
|
||||
estimatedTimeRemaining: '15 minutos',
|
||||
overallScore: 87
|
||||
};
|
||||
|
||||
const sectionReview = [
|
||||
{
|
||||
id: 'business-info',
|
||||
title: 'Información del Negocio',
|
||||
status: 'completed',
|
||||
score: 98,
|
||||
items: [
|
||||
{ field: 'Nombre de la panadería', value: 'Panadería Artesanal El Buen Pan', status: 'complete' },
|
||||
{ field: 'Dirección', value: 'Av. Principal 123, Centro', status: 'complete' },
|
||||
{ field: 'Teléfono', value: '+1 234 567 8900', status: 'complete' },
|
||||
{ field: 'Email', value: 'info@elbuenpan.com', status: 'complete' },
|
||||
{ field: 'Tipo de negocio', value: 'Panadería y Pastelería Artesanal', status: 'complete' },
|
||||
{ field: 'Horario de operación', value: 'L-V: 6:00-20:00, S-D: 7:00-18:00', status: 'complete' }
|
||||
],
|
||||
recommendations: []
|
||||
},
|
||||
{
|
||||
id: 'menu-products',
|
||||
title: 'Menú y Productos',
|
||||
status: 'completed',
|
||||
score: 85,
|
||||
items: [
|
||||
{ field: 'Productos de panadería', value: '24 productos configurados', status: 'complete' },
|
||||
{ field: 'Productos de pastelería', value: '18 productos configurados', status: 'complete' },
|
||||
{ field: 'Bebidas', value: '12 opciones disponibles', status: 'complete' },
|
||||
{ field: 'Precios configurados', value: '51 de 54 productos (94%)', status: 'warning' },
|
||||
{ field: 'Categorías organizadas', value: '6 categorías principales', status: 'complete' },
|
||||
{ field: 'Descripciones', value: '48 de 54 productos (89%)', status: 'warning' }
|
||||
],
|
||||
recommendations: [
|
||||
'Completar precios para 3 productos pendientes',
|
||||
'Añadir descripciones para 6 productos restantes'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
title: 'Inventario Inicial',
|
||||
status: 'completed',
|
||||
score: 92,
|
||||
items: [
|
||||
{ field: 'Materias primas', value: '45 ingredientes registrados', status: 'complete' },
|
||||
{ field: 'Proveedores', value: '8 proveedores configurados', status: 'complete' },
|
||||
{ field: 'Stocks iniciales', value: '43 de 45 ingredientes (96%)', status: 'warning' },
|
||||
{ field: 'Puntos de reorden', value: '40 de 45 ingredientes (89%)', status: 'warning' },
|
||||
{ field: 'Costos unitarios', value: 'Completado al 100%', status: 'complete' }
|
||||
],
|
||||
recommendations: [
|
||||
'Definir stocks iniciales para 2 ingredientes',
|
||||
'Establecer puntos de reorden para 5 ingredientes'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'staff-config',
|
||||
title: 'Configuración de Personal',
|
||||
status: 'completed',
|
||||
score: 90,
|
||||
items: [
|
||||
{ field: 'Empleados registrados', value: '12 miembros del equipo', status: 'complete' },
|
||||
{ field: 'Roles asignados', value: '5 roles diferentes configurados', status: 'complete' },
|
||||
{ field: 'Horarios de trabajo', value: '11 de 12 empleados (92%)', status: 'warning' },
|
||||
{ field: 'Permisos del sistema', value: 'Configurado completamente', status: 'complete' },
|
||||
{ field: 'Datos de contacto', value: 'Completado al 100%', status: 'complete' }
|
||||
],
|
||||
recommendations: [
|
||||
'Completar horario para 1 empleado pendiente'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
title: 'Configuración Operativa',
|
||||
status: 'completed',
|
||||
score: 95,
|
||||
items: [
|
||||
{ field: 'Horarios de operación', value: 'Configurado completamente', status: 'complete' },
|
||||
{ field: 'Métodos de pago', value: '4 métodos activos', status: 'complete' },
|
||||
{ field: 'Políticas de devoluciones', value: 'Definidas y configuradas', status: 'complete' },
|
||||
{ field: 'Configuración de impuestos', value: '18% IVA configurado', status: 'complete' },
|
||||
{ field: 'Zonas de entrega', value: '3 zonas configuradas', status: 'complete' }
|
||||
],
|
||||
recommendations: []
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Integraciones',
|
||||
status: 'completed',
|
||||
score: 88,
|
||||
items: [
|
||||
{ field: 'Sistema de pagos', value: 'Stripe configurado', status: 'complete' },
|
||||
{ field: 'Notificaciones por email', value: 'SMTP configurado', status: 'complete' },
|
||||
{ field: 'Notificaciones SMS', value: 'Twilio configurado', status: 'complete' },
|
||||
{ field: 'Backup automático', value: 'Configurado diariamente', status: 'complete' },
|
||||
{ field: 'APIs externas', value: '2 de 3 configuradas', status: 'warning' }
|
||||
],
|
||||
recommendations: [
|
||||
'Configurar API de delivery restante'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'testing',
|
||||
title: 'Pruebas del Sistema',
|
||||
status: 'pending',
|
||||
score: 0,
|
||||
items: [
|
||||
{ field: 'Prueba de ventas', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Prueba de inventario', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Prueba de reportes', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Prueba de notificaciones', value: 'Pendiente', status: 'pending' },
|
||||
{ field: 'Validación de integraciones', value: 'Pendiente', status: 'pending' }
|
||||
],
|
||||
recommendations: [
|
||||
'Ejecutar todas las pruebas del sistema antes del lanzamiento'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'training',
|
||||
title: 'Capacitación del Equipo',
|
||||
status: 'completed',
|
||||
score: 82,
|
||||
items: [
|
||||
{ field: 'Capacitación básica', value: '10 de 12 empleados (83%)', status: 'warning' },
|
||||
{ field: 'Manual del usuario', value: 'Entregado y revisado', status: 'complete' },
|
||||
{ field: 'Sesiones prácticas', value: '2 de 3 sesiones completadas', status: 'warning' },
|
||||
{ field: 'Evaluaciones', value: '8 de 10 empleados aprobados', status: 'warning' },
|
||||
{ field: 'Material de referencia', value: 'Disponible en el sistema', status: 'complete' }
|
||||
],
|
||||
recommendations: [
|
||||
'Completar capacitación para 2 empleados pendientes',
|
||||
'Programar tercera sesión práctica',
|
||||
'Realizar evaluaciones pendientes'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const overallRecommendations = [
|
||||
{
|
||||
priority: 'high',
|
||||
category: 'Crítico',
|
||||
title: 'Completar Pruebas del Sistema',
|
||||
description: 'Ejecutar todas las pruebas funcionales antes del lanzamiento',
|
||||
estimatedTime: '30 minutos',
|
||||
impact: 'Garantiza funcionamiento correcto del sistema'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
category: 'Importante',
|
||||
title: 'Finalizar Configuración de Productos',
|
||||
description: 'Completar precios y descripciones pendientes',
|
||||
estimatedTime: '20 minutos',
|
||||
impact: 'Permite ventas completas de todos los productos'
|
||||
},
|
||||
{
|
||||
priority: 'medium',
|
||||
category: 'Importante',
|
||||
title: 'Completar Capacitación del Personal',
|
||||
description: 'Finalizar entrenamiento para empleados pendientes',
|
||||
estimatedTime: '45 minutos',
|
||||
impact: 'Asegura operación eficiente desde el primer día'
|
||||
},
|
||||
{
|
||||
priority: 'low',
|
||||
category: 'Opcional',
|
||||
title: 'Optimizar Configuración de Inventario',
|
||||
description: 'Definir stocks y puntos de reorden pendientes',
|
||||
estimatedTime: '15 minutos',
|
||||
impact: 'Mejora control automático de inventario'
|
||||
}
|
||||
];
|
||||
|
||||
const launchReadiness = {
|
||||
essential: {
|
||||
completed: 6,
|
||||
total: 7,
|
||||
percentage: 86
|
||||
},
|
||||
recommended: {
|
||||
completed: 8,
|
||||
total: 12,
|
||||
percentage: 67
|
||||
},
|
||||
optional: {
|
||||
completed: 3,
|
||||
total: 6,
|
||||
percentage: 50
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle {...iconProps} className="w-5 h-5 text-green-600" />;
|
||||
case 'warning': return <AlertCircle {...iconProps} className="w-5 h-5 text-yellow-600" />;
|
||||
case 'pending': return <Clock {...iconProps} className="w-5 h-5 text-gray-600" />;
|
||||
default: return <AlertCircle {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green';
|
||||
case 'warning': return 'yellow';
|
||||
case 'pending': return 'gray';
|
||||
default: return 'red';
|
||||
}
|
||||
};
|
||||
|
||||
const getItemStatusIcon = (status: string) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
switch (status) {
|
||||
case 'complete': return <CheckCircle {...iconProps} className="w-4 h-4 text-green-600" />;
|
||||
case 'warning': return <AlertCircle {...iconProps} className="w-4 h-4 text-yellow-600" />;
|
||||
case 'pending': return <Clock {...iconProps} className="w-4 h-4 text-gray-600" />;
|
||||
default: return <AlertCircle {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'text-green-600';
|
||||
if (score >= 80) return 'text-yellow-600';
|
||||
if (score >= 70) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Revisión Final de Configuración"
|
||||
description="Verifica todos los aspectos de tu configuración antes del lanzamiento"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Editar Configuración
|
||||
</Button>
|
||||
<Button>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Lanzar Sistema
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Overall Progress */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="relative w-20 h-20 mx-auto mb-3">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(completionData.overallScore)}`}>
|
||||
{completionData.overallScore}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="w-20 h-20 transform -rotate-90">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
strokeDasharray={`${completionData.overallScore * 2.01} 201`}
|
||||
className="text-green-600"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">Puntuación General</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-600">{completionData.overallProgress}%</p>
|
||||
<p className="text-sm font-medium text-gray-700">Progreso Total</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${completionData.overallProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-purple-600">
|
||||
{completionData.completedSteps}/{completionData.totalSteps}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-700">Secciones Completadas</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-orange-600">{completionData.estimatedTimeRemaining}</p>
|
||||
<p className="text-sm font-medium text-gray-700">Tiempo Restante</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{['overview', 'sections', 'recommendations', 'readiness'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSection(tab)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeSection === tab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'overview' && 'Resumen General'}
|
||||
{tab === 'sections' && 'Revisión por Secciones'}
|
||||
{tab === 'recommendations' && 'Recomendaciones'}
|
||||
{tab === 'readiness' && 'Preparación para Lanzamiento'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content based on active section */}
|
||||
{activeSection === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Estado por Secciones</h3>
|
||||
<div className="space-y-3">
|
||||
{sectionReview.map((section) => (
|
||||
<div key={section.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(section.status)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{section.title}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{section.recommendations.length > 0
|
||||
? `${section.recommendations.length} recomendación${section.recommendations.length > 1 ? 'es' : ''}`
|
||||
: 'Completado correctamente'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${getScoreColor(section.score)}`}>
|
||||
{section.score}%
|
||||
</p>
|
||||
<Badge variant={getStatusColor(section.status)}>
|
||||
{section.status === 'completed' ? 'Completado' :
|
||||
section.status === 'warning' ? 'Con observaciones' : 'Pendiente'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Próximos Pasos</h3>
|
||||
<div className="space-y-4">
|
||||
{overallRecommendations.slice(0, 3).map((rec, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 border rounded-lg">
|
||||
<Badge variant={getPriorityColor(rec.priority)} className="mt-1">
|
||||
{rec.category}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">{rec.title}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{rec.description}</p>
|
||||
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
|
||||
<span>⏱️ {rec.estimatedTime}</span>
|
||||
<span>💡 {rec.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-400 mt-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Star className="w-5 h-5 text-blue-600" />
|
||||
<h4 className="font-medium text-blue-900">¡Excelente progreso!</h4>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800">
|
||||
Has completado el 95% de la configuración. Solo quedan algunos detalles finales antes del lanzamiento.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'sections' && (
|
||||
<div className="space-y-6">
|
||||
{sectionReview.map((section) => (
|
||||
<Card key={section.id} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(section.status)}
|
||||
<h3 className="text-lg font-semibold text-gray-900">{section.title}</h3>
|
||||
<Badge variant={getStatusColor(section.status)}>
|
||||
Puntuación: {section.score}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{section.items.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getItemStatusIcon(item.status)}
|
||||
<span className="text-sm font-medium text-gray-700">{item.field}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{section.recommendations.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-yellow-800 mb-2">Recomendaciones:</h4>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
{section.recommendations.map((rec, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'recommendations' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Todas las Recomendaciones</h3>
|
||||
<div className="space-y-4">
|
||||
{overallRecommendations.map((rec, index) => (
|
||||
<div key={index} className="flex items-start justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<Badge variant={getPriorityColor(rec.priority)}>
|
||||
{rec.category}
|
||||
</Badge>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">{rec.title}</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">{rec.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>⏱️ Tiempo estimado: {rec.estimatedTime}</span>
|
||||
<span>💡 Impacto: {rec.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">Más Información</Button>
|
||||
<Button size="sm">Completar</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'readiness' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Preparación para el Lanzamiento</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="w-16 h-16 mx-auto mb-3 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Elementos Esenciales</h4>
|
||||
<p className="text-2xl font-bold text-green-600 mb-1">
|
||||
{launchReadiness.essential.completed}/{launchReadiness.essential.total}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{launchReadiness.essential.percentage}% completado</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full"
|
||||
style={{ width: `${launchReadiness.essential.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="w-16 h-16 mx-auto mb-3 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<Star className="w-8 h-8 text-yellow-600" />
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Recomendados</h4>
|
||||
<p className="text-2xl font-bold text-yellow-600 mb-1">
|
||||
{launchReadiness.recommended.completed}/{launchReadiness.recommended.total}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{launchReadiness.recommended.percentage}% completado</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-yellow-600 h-2 rounded-full"
|
||||
style={{ width: `${launchReadiness.recommended.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="w-16 h-16 mx-auto mb-3 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Opcionales</h4>
|
||||
<p className="text-2xl font-bold text-blue-600 mb-1">
|
||||
{launchReadiness.optional.completed}/{launchReadiness.optional.total}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{launchReadiness.optional.percentage}% completado</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${launchReadiness.optional.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
<h4 className="text-lg font-semibold text-green-900">¡Listo para Lanzar!</h4>
|
||||
</div>
|
||||
<p className="text-green-800 mb-4">
|
||||
Tu configuración está lista para el lanzamiento. Todos los elementos esenciales están completados
|
||||
y el sistema está preparado para comenzar a operar.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Lanzar Ahora
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
Ejecutar Pruebas Finales
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingReviewPage;
|
||||
1
frontend/src/pages/app/onboarding/review/index.ts
Normal file
1
frontend/src/pages/app/onboarding/review/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OnboardingReviewPage } from './OnboardingReviewPage';
|
||||
499
frontend/src/pages/app/onboarding/setup/OnboardingSetupPage.tsx
Normal file
499
frontend/src/pages/app/onboarding/setup/OnboardingSetupPage.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronRight, ChevronLeft, Check, Store, Users, Settings, Zap } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingSetupPage: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
bakery: {
|
||||
name: '',
|
||||
type: 'traditional',
|
||||
size: 'medium',
|
||||
location: '',
|
||||
phone: '',
|
||||
email: ''
|
||||
},
|
||||
team: {
|
||||
ownerName: '',
|
||||
teamSize: '5-10',
|
||||
roles: [],
|
||||
experience: 'intermediate'
|
||||
},
|
||||
operations: {
|
||||
openingHours: {
|
||||
start: '07:00',
|
||||
end: '20:00'
|
||||
},
|
||||
daysOpen: 6,
|
||||
specialties: [],
|
||||
dailyProduction: 'medium'
|
||||
},
|
||||
goals: {
|
||||
primaryGoals: [],
|
||||
expectedRevenue: '',
|
||||
timeline: '6months'
|
||||
}
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Información de la Panadería',
|
||||
description: 'Detalles básicos sobre tu negocio',
|
||||
icon: Store,
|
||||
fields: ['name', 'type', 'location', 'contact']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Equipo y Personal',
|
||||
description: 'Información sobre tu equipo de trabajo',
|
||||
icon: Users,
|
||||
fields: ['owner', 'teamSize', 'roles', 'experience']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Operaciones',
|
||||
description: 'Horarios y especialidades de producción',
|
||||
icon: Settings,
|
||||
fields: ['hours', 'specialties', 'production']
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Objetivos',
|
||||
description: 'Metas y expectativas para tu panadería',
|
||||
icon: Zap,
|
||||
fields: ['goals', 'revenue', 'timeline']
|
||||
}
|
||||
];
|
||||
|
||||
const bakeryTypes = [
|
||||
{ value: 'traditional', label: 'Panadería Tradicional' },
|
||||
{ value: 'artisan', label: 'Panadería Artesanal' },
|
||||
{ value: 'cafe', label: 'Panadería-Café' },
|
||||
{ value: 'industrial', label: 'Producción Industrial' }
|
||||
];
|
||||
|
||||
const specialties = [
|
||||
{ value: 'bread', label: 'Pan Tradicional' },
|
||||
{ value: 'pastries', label: 'Bollería' },
|
||||
{ value: 'cakes', label: 'Tartas y Pasteles' },
|
||||
{ value: 'cookies', label: 'Galletas' },
|
||||
{ value: 'savory', label: 'Productos Salados' },
|
||||
{ value: 'gluten-free', label: 'Sin Gluten' },
|
||||
{ value: 'vegan', label: 'Vegano' },
|
||||
{ value: 'organic', label: 'Orgánico' }
|
||||
];
|
||||
|
||||
const businessGoals = [
|
||||
{ value: 'increase-sales', label: 'Aumentar Ventas' },
|
||||
{ value: 'reduce-waste', label: 'Reducir Desperdicios' },
|
||||
{ value: 'improve-efficiency', label: 'Mejorar Eficiencia' },
|
||||
{ value: 'expand-menu', label: 'Ampliar Menú' },
|
||||
{ value: 'digital-presence', label: 'Presencia Digital' },
|
||||
{ value: 'customer-loyalty', label: 'Fidelización de Clientes' }
|
||||
];
|
||||
|
||||
const handleInputChange = (section: string, field: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleArrayToggle = (section: string, field: string, value: string) => {
|
||||
setFormData(prev => {
|
||||
const currentArray = prev[section as keyof typeof prev][field] || [];
|
||||
const newArray = currentArray.includes(value)
|
||||
? currentArray.filter((item: string) => item !== value)
|
||||
: [...currentArray, value];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: newArray
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < steps.length) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
console.log('Onboarding completed:', formData);
|
||||
// Handle completion logic
|
||||
};
|
||||
|
||||
const isStepComplete = (stepId: number) => {
|
||||
// Basic validation logic
|
||||
switch (stepId) {
|
||||
case 1:
|
||||
return formData.bakery.name && formData.bakery.location;
|
||||
case 2:
|
||||
return formData.team.ownerName;
|
||||
case 3:
|
||||
return formData.operations.specialties.length > 0;
|
||||
case 4:
|
||||
return formData.goals.primaryGoals.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Nombre de la Panadería *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.name}
|
||||
onChange={(e) => handleInputChange('bakery', 'name', e.target.value)}
|
||||
placeholder="Ej: Panadería San Miguel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Tipo de Panadería
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{bakeryTypes.map((type) => (
|
||||
<label key={type.value} className="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-[var(--bg-secondary)]">
|
||||
<input
|
||||
type="radio"
|
||||
name="bakeryType"
|
||||
value={type.value}
|
||||
checked={formData.bakery.type === type.value}
|
||||
onChange={(e) => handleInputChange('bakery', 'type', e.target.value)}
|
||||
className="text-[var(--color-info)]"
|
||||
/>
|
||||
<span className="text-sm">{type.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Ubicación *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.location}
|
||||
onChange={(e) => handleInputChange('bakery', 'location', e.target.value)}
|
||||
placeholder="Dirección completa"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.phone}
|
||||
onChange={(e) => handleInputChange('bakery', 'phone', e.target.value)}
|
||||
placeholder="+34 xxx xxx xxx"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.email}
|
||||
onChange={(e) => handleInputChange('bakery', 'email', e.target.value)}
|
||||
placeholder="contacto@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Nombre del Propietario *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.team.ownerName}
|
||||
onChange={(e) => handleInputChange('team', 'ownerName', e.target.value)}
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Tamaño del Equipo
|
||||
</label>
|
||||
<select
|
||||
value={formData.team.teamSize}
|
||||
onChange={(e) => handleInputChange('team', 'teamSize', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="1-2">Solo yo o 1-2 personas</option>
|
||||
<option value="3-5">3-5 empleados</option>
|
||||
<option value="5-10">5-10 empleados</option>
|
||||
<option value="10+">Más de 10 empleados</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Experiencia en el Sector
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'beginner', label: 'Principiante (menos de 2 años)' },
|
||||
{ value: 'intermediate', label: 'Intermedio (2-5 años)' },
|
||||
{ value: 'experienced', label: 'Experimentado (5-10 años)' },
|
||||
{ value: 'expert', label: 'Experto (más de 10 años)' }
|
||||
].map((exp) => (
|
||||
<label key={exp.value} className="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="experience"
|
||||
value={exp.value}
|
||||
checked={formData.team.experience === exp.value}
|
||||
onChange={(e) => handleInputChange('team', 'experience', e.target.value)}
|
||||
className="text-[var(--color-info)]"
|
||||
/>
|
||||
<span className="text-sm">{exp.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Hora de Apertura
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.operations.openingHours.start}
|
||||
onChange={(e) => handleInputChange('operations', 'openingHours', {
|
||||
...formData.operations.openingHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Hora de Cierre
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.operations.openingHours.end}
|
||||
onChange={(e) => handleInputChange('operations', 'openingHours', {
|
||||
...formData.operations.openingHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Días de Operación por Semana
|
||||
</label>
|
||||
<select
|
||||
value={formData.operations.daysOpen}
|
||||
onChange={(e) => handleInputChange('operations', 'daysOpen', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value={5}>5 días</option>
|
||||
<option value={6}>6 días</option>
|
||||
<option value={7}>7 días</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-3">
|
||||
Especialidades *
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{specialties.map((specialty) => (
|
||||
<label key={specialty.value} className="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-[var(--bg-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.operations.specialties.includes(specialty.value)}
|
||||
onChange={() => handleArrayToggle('operations', 'specialties', specialty.value)}
|
||||
className="text-[var(--color-info)] rounded"
|
||||
/>
|
||||
<span className="text-sm">{specialty.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-3">
|
||||
Objetivos Principales *
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{businessGoals.map((goal) => (
|
||||
<label key={goal.value} className="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-[var(--bg-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.goals.primaryGoals.includes(goal.value)}
|
||||
onChange={() => handleArrayToggle('goals', 'primaryGoals', goal.value)}
|
||||
className="text-[var(--color-info)] rounded"
|
||||
/>
|
||||
<span className="text-sm">{goal.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Ingresos Mensuales Esperados (opcional)
|
||||
</label>
|
||||
<select
|
||||
value={formData.goals.expectedRevenue}
|
||||
onChange={(e) => handleInputChange('goals', 'expectedRevenue', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="">Seleccionar rango</option>
|
||||
<option value="0-5000">Menos de €5,000</option>
|
||||
<option value="5000-15000">€5,000 - €15,000</option>
|
||||
<option value="15000-30000">€15,000 - €30,000</option>
|
||||
<option value="30000+">Más de €30,000</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Plazo para Alcanzar Objetivos
|
||||
</label>
|
||||
<select
|
||||
value={formData.goals.timeline}
|
||||
onChange={(e) => handleInputChange('goals', 'timeline', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="3months">3 meses</option>
|
||||
<option value="6months">6 meses</option>
|
||||
<option value="1year">1 año</option>
|
||||
<option value="2years">2 años o más</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<PageHeader
|
||||
title="Configuración Inicial"
|
||||
description="Configura tu panadería paso a paso para comenzar"
|
||||
/>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<Card className="p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
step.id === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: step.id < currentStep || isStepComplete(step.id)
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-[var(--bg-quaternary)] text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
{step.id < currentStep || (step.id === currentStep && isStepComplete(step.id)) ? (
|
||||
<Check className="w-5 h-5" />
|
||||
) : (
|
||||
<step.icon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-full h-1 mx-4 ${
|
||||
step.id < currentStep ? 'bg-green-600' : 'bg-[var(--bg-quaternary)]'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
|
||||
Paso {currentStep}: {steps[currentStep - 1].title}
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{steps[currentStep - 1].description}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card className="p-8 mb-8">
|
||||
{renderStepContent()}
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
{currentStep === steps.length ? (
|
||||
<Button onClick={handleFinish}>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Finalizar Configuración
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={!isStepComplete(currentStep)}
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingSetupPage;
|
||||
@@ -0,0 +1,499 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronRight, ChevronLeft, Check, Store, Users, Settings, Zap } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingSetupPage: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
bakery: {
|
||||
name: '',
|
||||
type: 'traditional',
|
||||
size: 'medium',
|
||||
location: '',
|
||||
phone: '',
|
||||
email: ''
|
||||
},
|
||||
team: {
|
||||
ownerName: '',
|
||||
teamSize: '5-10',
|
||||
roles: [],
|
||||
experience: 'intermediate'
|
||||
},
|
||||
operations: {
|
||||
openingHours: {
|
||||
start: '07:00',
|
||||
end: '20:00'
|
||||
},
|
||||
daysOpen: 6,
|
||||
specialties: [],
|
||||
dailyProduction: 'medium'
|
||||
},
|
||||
goals: {
|
||||
primaryGoals: [],
|
||||
expectedRevenue: '',
|
||||
timeline: '6months'
|
||||
}
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Información de la Panadería',
|
||||
description: 'Detalles básicos sobre tu negocio',
|
||||
icon: Store,
|
||||
fields: ['name', 'type', 'location', 'contact']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Equipo y Personal',
|
||||
description: 'Información sobre tu equipo de trabajo',
|
||||
icon: Users,
|
||||
fields: ['owner', 'teamSize', 'roles', 'experience']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Operaciones',
|
||||
description: 'Horarios y especialidades de producción',
|
||||
icon: Settings,
|
||||
fields: ['hours', 'specialties', 'production']
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Objetivos',
|
||||
description: 'Metas y expectativas para tu panadería',
|
||||
icon: Zap,
|
||||
fields: ['goals', 'revenue', 'timeline']
|
||||
}
|
||||
];
|
||||
|
||||
const bakeryTypes = [
|
||||
{ value: 'traditional', label: 'Panadería Tradicional' },
|
||||
{ value: 'artisan', label: 'Panadería Artesanal' },
|
||||
{ value: 'cafe', label: 'Panadería-Café' },
|
||||
{ value: 'industrial', label: 'Producción Industrial' }
|
||||
];
|
||||
|
||||
const specialties = [
|
||||
{ value: 'bread', label: 'Pan Tradicional' },
|
||||
{ value: 'pastries', label: 'Bollería' },
|
||||
{ value: 'cakes', label: 'Tartas y Pasteles' },
|
||||
{ value: 'cookies', label: 'Galletas' },
|
||||
{ value: 'savory', label: 'Productos Salados' },
|
||||
{ value: 'gluten-free', label: 'Sin Gluten' },
|
||||
{ value: 'vegan', label: 'Vegano' },
|
||||
{ value: 'organic', label: 'Orgánico' }
|
||||
];
|
||||
|
||||
const businessGoals = [
|
||||
{ value: 'increase-sales', label: 'Aumentar Ventas' },
|
||||
{ value: 'reduce-waste', label: 'Reducir Desperdicios' },
|
||||
{ value: 'improve-efficiency', label: 'Mejorar Eficiencia' },
|
||||
{ value: 'expand-menu', label: 'Ampliar Menú' },
|
||||
{ value: 'digital-presence', label: 'Presencia Digital' },
|
||||
{ value: 'customer-loyalty', label: 'Fidelización de Clientes' }
|
||||
];
|
||||
|
||||
const handleInputChange = (section: string, field: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleArrayToggle = (section: string, field: string, value: string) => {
|
||||
setFormData(prev => {
|
||||
const currentArray = prev[section as keyof typeof prev][field] || [];
|
||||
const newArray = currentArray.includes(value)
|
||||
? currentArray.filter((item: string) => item !== value)
|
||||
: [...currentArray, value];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: newArray
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < steps.length) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
console.log('Onboarding completed:', formData);
|
||||
// Handle completion logic
|
||||
};
|
||||
|
||||
const isStepComplete = (stepId: number) => {
|
||||
// Basic validation logic
|
||||
switch (stepId) {
|
||||
case 1:
|
||||
return formData.bakery.name && formData.bakery.location;
|
||||
case 2:
|
||||
return formData.team.ownerName;
|
||||
case 3:
|
||||
return formData.operations.specialties.length > 0;
|
||||
case 4:
|
||||
return formData.goals.primaryGoals.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre de la Panadería *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.name}
|
||||
onChange={(e) => handleInputChange('bakery', 'name', e.target.value)}
|
||||
placeholder="Ej: Panadería San Miguel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de Panadería
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{bakeryTypes.map((type) => (
|
||||
<label key={type.value} className="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
name="bakeryType"
|
||||
value={type.value}
|
||||
checked={formData.bakery.type === type.value}
|
||||
onChange={(e) => handleInputChange('bakery', 'type', e.target.value)}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">{type.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ubicación *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.location}
|
||||
onChange={(e) => handleInputChange('bakery', 'location', e.target.value)}
|
||||
placeholder="Dirección completa"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.phone}
|
||||
onChange={(e) => handleInputChange('bakery', 'phone', e.target.value)}
|
||||
placeholder="+34 xxx xxx xxx"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bakery.email}
|
||||
onChange={(e) => handleInputChange('bakery', 'email', e.target.value)}
|
||||
placeholder="contacto@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre del Propietario *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.team.ownerName}
|
||||
onChange={(e) => handleInputChange('team', 'ownerName', e.target.value)}
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tamaño del Equipo
|
||||
</label>
|
||||
<select
|
||||
value={formData.team.teamSize}
|
||||
onChange={(e) => handleInputChange('team', 'teamSize', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="1-2">Solo yo o 1-2 personas</option>
|
||||
<option value="3-5">3-5 empleados</option>
|
||||
<option value="5-10">5-10 empleados</option>
|
||||
<option value="10+">Más de 10 empleados</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Experiencia en el Sector
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'beginner', label: 'Principiante (menos de 2 años)' },
|
||||
{ value: 'intermediate', label: 'Intermedio (2-5 años)' },
|
||||
{ value: 'experienced', label: 'Experimentado (5-10 años)' },
|
||||
{ value: 'expert', label: 'Experto (más de 10 años)' }
|
||||
].map((exp) => (
|
||||
<label key={exp.value} className="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="experience"
|
||||
value={exp.value}
|
||||
checked={formData.team.experience === exp.value}
|
||||
onChange={(e) => handleInputChange('team', 'experience', e.target.value)}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">{exp.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de Apertura
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.operations.openingHours.start}
|
||||
onChange={(e) => handleInputChange('operations', 'openingHours', {
|
||||
...formData.operations.openingHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de Cierre
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.operations.openingHours.end}
|
||||
onChange={(e) => handleInputChange('operations', 'openingHours', {
|
||||
...formData.operations.openingHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Días de Operación por Semana
|
||||
</label>
|
||||
<select
|
||||
value={formData.operations.daysOpen}
|
||||
onChange={(e) => handleInputChange('operations', 'daysOpen', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value={5}>5 días</option>
|
||||
<option value={6}>6 días</option>
|
||||
<option value={7}>7 días</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Especialidades *
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{specialties.map((specialty) => (
|
||||
<label key={specialty.value} className="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.operations.specialties.includes(specialty.value)}
|
||||
onChange={() => handleArrayToggle('operations', 'specialties', specialty.value)}
|
||||
className="text-blue-600 rounded"
|
||||
/>
|
||||
<span className="text-sm">{specialty.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Objetivos Principales *
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{businessGoals.map((goal) => (
|
||||
<label key={goal.value} className="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.goals.primaryGoals.includes(goal.value)}
|
||||
onChange={() => handleArrayToggle('goals', 'primaryGoals', goal.value)}
|
||||
className="text-blue-600 rounded"
|
||||
/>
|
||||
<span className="text-sm">{goal.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ingresos Mensuales Esperados (opcional)
|
||||
</label>
|
||||
<select
|
||||
value={formData.goals.expectedRevenue}
|
||||
onChange={(e) => handleInputChange('goals', 'expectedRevenue', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">Seleccionar rango</option>
|
||||
<option value="0-5000">Menos de €5,000</option>
|
||||
<option value="5000-15000">€5,000 - €15,000</option>
|
||||
<option value="15000-30000">€15,000 - €30,000</option>
|
||||
<option value="30000+">Más de €30,000</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plazo para Alcanzar Objetivos
|
||||
</label>
|
||||
<select
|
||||
value={formData.goals.timeline}
|
||||
onChange={(e) => handleInputChange('goals', 'timeline', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="3months">3 meses</option>
|
||||
<option value="6months">6 meses</option>
|
||||
<option value="1year">1 año</option>
|
||||
<option value="2years">2 años o más</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<PageHeader
|
||||
title="Configuración Inicial"
|
||||
description="Configura tu panadería paso a paso para comenzar"
|
||||
/>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<Card className="p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
step.id === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: step.id < currentStep || isStepComplete(step.id)
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{step.id < currentStep || (step.id === currentStep && isStepComplete(step.id)) ? (
|
||||
<Check className="w-5 h-5" />
|
||||
) : (
|
||||
<step.icon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-full h-1 mx-4 ${
|
||||
step.id < currentStep ? 'bg-green-600' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Paso {currentStep}: {steps[currentStep - 1].title}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{steps[currentStep - 1].description}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card className="p-8 mb-8">
|
||||
{renderStepContent()}
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
{currentStep === steps.length ? (
|
||||
<Button onClick={handleFinish}>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Finalizar Configuración
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={!isStepComplete(currentStep)}
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingSetupPage;
|
||||
1
frontend/src/pages/app/onboarding/setup/index.ts
Normal file
1
frontend/src/pages/app/onboarding/setup/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OnboardingSetupPage } from './OnboardingSetupPage';
|
||||
@@ -0,0 +1,438 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, File, Check, AlertCircle, Download, RefreshCw, Trash2, Eye } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingUploadPage: React.FC = () => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const uploadedFiles = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'productos_menu.csv',
|
||||
type: 'productos',
|
||||
size: '45 KB',
|
||||
status: 'completed',
|
||||
uploadedAt: '2024-01-26 10:30:00',
|
||||
records: 127,
|
||||
errors: 3,
|
||||
warnings: 8
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'inventario_inicial.xlsx',
|
||||
type: 'inventario',
|
||||
size: '82 KB',
|
||||
status: 'completed',
|
||||
uploadedAt: '2024-01-26 10:25:00',
|
||||
records: 89,
|
||||
errors: 0,
|
||||
warnings: 2
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'empleados.csv',
|
||||
type: 'empleados',
|
||||
size: '12 KB',
|
||||
status: 'processing',
|
||||
uploadedAt: '2024-01-26 10:35:00',
|
||||
records: 8,
|
||||
errors: 0,
|
||||
warnings: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'ventas_historicas.csv',
|
||||
type: 'ventas',
|
||||
size: '256 KB',
|
||||
status: 'error',
|
||||
uploadedAt: '2024-01-26 10:20:00',
|
||||
records: 0,
|
||||
errors: 1,
|
||||
warnings: 0,
|
||||
errorMessage: 'Formato de fecha incorrecto en la columna "fecha_venta"'
|
||||
}
|
||||
];
|
||||
|
||||
const supportedFormats = [
|
||||
{
|
||||
type: 'productos',
|
||||
name: 'Productos y Menú',
|
||||
formats: ['CSV', 'Excel'],
|
||||
description: 'Lista de productos con precios, categorías y descripciones',
|
||||
template: 'template_productos.csv',
|
||||
requiredFields: ['nombre', 'categoria', 'precio', 'descripcion']
|
||||
},
|
||||
{
|
||||
type: 'inventario',
|
||||
name: 'Inventario Inicial',
|
||||
formats: ['CSV', 'Excel'],
|
||||
description: 'Stock inicial de ingredientes y materias primas',
|
||||
template: 'template_inventario.xlsx',
|
||||
requiredFields: ['item', 'cantidad', 'unidad', 'costo_unitario']
|
||||
},
|
||||
{
|
||||
type: 'empleados',
|
||||
name: 'Empleados',
|
||||
formats: ['CSV'],
|
||||
description: 'Información del personal y roles',
|
||||
template: 'template_empleados.csv',
|
||||
requiredFields: ['nombre', 'cargo', 'email', 'telefono']
|
||||
},
|
||||
{
|
||||
type: 'ventas',
|
||||
name: 'Historial de Ventas',
|
||||
formats: ['CSV'],
|
||||
description: 'Datos históricos de ventas para análisis',
|
||||
template: 'template_ventas.csv',
|
||||
requiredFields: ['fecha', 'producto', 'cantidad', 'total']
|
||||
},
|
||||
{
|
||||
type: 'proveedores',
|
||||
name: 'Proveedores',
|
||||
formats: ['CSV', 'Excel'],
|
||||
description: 'Lista de proveedores y datos de contacto',
|
||||
template: 'template_proveedores.csv',
|
||||
requiredFields: ['nombre', 'contacto', 'telefono', 'email']
|
||||
}
|
||||
];
|
||||
|
||||
const uploadStats = {
|
||||
totalFiles: uploadedFiles.length,
|
||||
completedFiles: uploadedFiles.filter(f => f.status === 'completed').length,
|
||||
totalRecords: uploadedFiles.reduce((sum, f) => sum + f.records, 0),
|
||||
totalErrors: uploadedFiles.reduce((sum, f) => sum + f.errors, 0),
|
||||
totalWarnings: uploadedFiles.reduce((sum, f) => sum + f.warnings, 0)
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (status) {
|
||||
case 'completed': return <Check {...iconProps} className="w-5 h-5 text-[var(--color-success)]" />;
|
||||
case 'processing': return <RefreshCw {...iconProps} className="w-5 h-5 text-[var(--color-info)] animate-spin" />;
|
||||
case 'error': return <AlertCircle {...iconProps} className="w-5 h-5 text-[var(--color-error)]" />;
|
||||
default: return <File {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green';
|
||||
case 'processing': return 'blue';
|
||||
case 'error': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'Completado';
|
||||
case 'processing': return 'Procesando';
|
||||
case 'error': return 'Error';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleFiles(files);
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
handleFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = (files: File[]) => {
|
||||
console.log('Files selected:', files);
|
||||
// Simulate upload progress
|
||||
setIsProcessing(true);
|
||||
setUploadProgress(0);
|
||||
const interval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval);
|
||||
setIsProcessing(false);
|
||||
return 100;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const downloadTemplate = (template: string) => {
|
||||
console.log('Downloading template:', template);
|
||||
// Handle template download
|
||||
};
|
||||
|
||||
const retryUpload = (fileId: string) => {
|
||||
console.log('Retrying upload for file:', fileId);
|
||||
// Handle retry logic
|
||||
};
|
||||
|
||||
const deleteFile = (fileId: string) => {
|
||||
console.log('Deleting file:', fileId);
|
||||
// Handle delete logic
|
||||
};
|
||||
|
||||
const viewDetails = (fileId: string) => {
|
||||
console.log('Viewing details for file:', fileId);
|
||||
// Handle view details logic
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Carga de Datos"
|
||||
description="Importa tus datos existentes para acelerar la configuración inicial"
|
||||
/>
|
||||
|
||||
{/* Upload Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Archivos Subidos</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{uploadStats.totalFiles}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Upload className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completados</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{uploadStats.completedFiles}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Check className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Registros</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{uploadStats.totalRecords}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<File className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Errores</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-error)]">{uploadStats.totalErrors}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Advertencias</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">{uploadStats.totalWarnings}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<Card className="p-8">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive
|
||||
? 'border-blue-400 bg-[var(--color-info)]/5'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--border-tertiary)]'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Arrastra archivos aquí o haz clic para seleccionar
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Formatos soportados: CSV, Excel (XLSX). Tamaño máximo: 10MB por archivo
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload">
|
||||
<Button className="cursor-pointer">
|
||||
Seleccionar Archivos
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="mt-4">
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Procesando... {uploadProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Formatos Soportados</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{supportedFormats.map((format, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{format.name}</h4>
|
||||
<div className="flex space-x-1">
|
||||
{format.formats.map((fmt, idx) => (
|
||||
<Badge key={idx} variant="blue" className="text-xs">{fmt}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">{format.description}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-[var(--text-secondary)] mb-1">Campos requeridos:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{format.requiredFields.map((field, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs rounded">
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => downloadTemplate(format.template)}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-2" />
|
||||
Descargar Plantilla
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Uploaded Files */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Archivos Cargados</h3>
|
||||
<div className="space-y-3">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
{getStatusIcon(file.status)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">{file.name}</h4>
|
||||
<Badge variant={getStatusColor(file.status)}>
|
||||
{getStatusLabel(file.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">{file.size}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)]">
|
||||
<span>{file.records} registros</span>
|
||||
{file.errors > 0 && (
|
||||
<span className="text-[var(--color-error)]">{file.errors} errores</span>
|
||||
)}
|
||||
{file.warnings > 0 && (
|
||||
<span className="text-yellow-600">{file.warnings} advertencias</span>
|
||||
)}
|
||||
<span>Subido: {new Date(file.uploadedAt).toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
|
||||
{file.status === 'error' && file.errorMessage && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-[var(--color-error)]">
|
||||
{file.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => viewDetails(file.id)}>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
{file.status === 'error' && (
|
||||
<Button size="sm" variant="outline" onClick={() => retryUpload(file.id)}>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => deleteFile(file.id)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Help Section */}
|
||||
<Card className="p-6 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-4">Tips para la Carga de Datos</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-[var(--color-info)]">
|
||||
<ul className="space-y-2">
|
||||
<li>• Usa las plantillas proporcionadas para garantizar el formato correcto</li>
|
||||
<li>• Verifica que todos los campos requeridos estén completos</li>
|
||||
<li>• Los archivos CSV deben usar codificación UTF-8</li>
|
||||
<li>• Las fechas deben estar en formato DD/MM/YYYY</li>
|
||||
</ul>
|
||||
<ul className="space-y-2">
|
||||
<li>• Los precios deben usar punto (.) como separador decimal</li>
|
||||
<li>• Evita caracteres especiales en los nombres de productos</li>
|
||||
<li>• Mantén los nombres de archivos descriptivos</li>
|
||||
<li>• Puedes cargar múltiples archivos del mismo tipo</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingUploadPage;
|
||||
@@ -0,0 +1,438 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, File, Check, AlertCircle, Download, RefreshCw, Trash2, Eye } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const OnboardingUploadPage: React.FC = () => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const uploadedFiles = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'productos_menu.csv',
|
||||
type: 'productos',
|
||||
size: '45 KB',
|
||||
status: 'completed',
|
||||
uploadedAt: '2024-01-26 10:30:00',
|
||||
records: 127,
|
||||
errors: 3,
|
||||
warnings: 8
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'inventario_inicial.xlsx',
|
||||
type: 'inventario',
|
||||
size: '82 KB',
|
||||
status: 'completed',
|
||||
uploadedAt: '2024-01-26 10:25:00',
|
||||
records: 89,
|
||||
errors: 0,
|
||||
warnings: 2
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'empleados.csv',
|
||||
type: 'empleados',
|
||||
size: '12 KB',
|
||||
status: 'processing',
|
||||
uploadedAt: '2024-01-26 10:35:00',
|
||||
records: 8,
|
||||
errors: 0,
|
||||
warnings: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'ventas_historicas.csv',
|
||||
type: 'ventas',
|
||||
size: '256 KB',
|
||||
status: 'error',
|
||||
uploadedAt: '2024-01-26 10:20:00',
|
||||
records: 0,
|
||||
errors: 1,
|
||||
warnings: 0,
|
||||
errorMessage: 'Formato de fecha incorrecto en la columna "fecha_venta"'
|
||||
}
|
||||
];
|
||||
|
||||
const supportedFormats = [
|
||||
{
|
||||
type: 'productos',
|
||||
name: 'Productos y Menú',
|
||||
formats: ['CSV', 'Excel'],
|
||||
description: 'Lista de productos con precios, categorías y descripciones',
|
||||
template: 'template_productos.csv',
|
||||
requiredFields: ['nombre', 'categoria', 'precio', 'descripcion']
|
||||
},
|
||||
{
|
||||
type: 'inventario',
|
||||
name: 'Inventario Inicial',
|
||||
formats: ['CSV', 'Excel'],
|
||||
description: 'Stock inicial de ingredientes y materias primas',
|
||||
template: 'template_inventario.xlsx',
|
||||
requiredFields: ['item', 'cantidad', 'unidad', 'costo_unitario']
|
||||
},
|
||||
{
|
||||
type: 'empleados',
|
||||
name: 'Empleados',
|
||||
formats: ['CSV'],
|
||||
description: 'Información del personal y roles',
|
||||
template: 'template_empleados.csv',
|
||||
requiredFields: ['nombre', 'cargo', 'email', 'telefono']
|
||||
},
|
||||
{
|
||||
type: 'ventas',
|
||||
name: 'Historial de Ventas',
|
||||
formats: ['CSV'],
|
||||
description: 'Datos históricos de ventas para análisis',
|
||||
template: 'template_ventas.csv',
|
||||
requiredFields: ['fecha', 'producto', 'cantidad', 'total']
|
||||
},
|
||||
{
|
||||
type: 'proveedores',
|
||||
name: 'Proveedores',
|
||||
formats: ['CSV', 'Excel'],
|
||||
description: 'Lista de proveedores y datos de contacto',
|
||||
template: 'template_proveedores.csv',
|
||||
requiredFields: ['nombre', 'contacto', 'telefono', 'email']
|
||||
}
|
||||
];
|
||||
|
||||
const uploadStats = {
|
||||
totalFiles: uploadedFiles.length,
|
||||
completedFiles: uploadedFiles.filter(f => f.status === 'completed').length,
|
||||
totalRecords: uploadedFiles.reduce((sum, f) => sum + f.records, 0),
|
||||
totalErrors: uploadedFiles.reduce((sum, f) => sum + f.errors, 0),
|
||||
totalWarnings: uploadedFiles.reduce((sum, f) => sum + f.warnings, 0)
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (status) {
|
||||
case 'completed': return <Check {...iconProps} className="w-5 h-5 text-green-600" />;
|
||||
case 'processing': return <RefreshCw {...iconProps} className="w-5 h-5 text-blue-600 animate-spin" />;
|
||||
case 'error': return <AlertCircle {...iconProps} className="w-5 h-5 text-red-600" />;
|
||||
default: return <File {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green';
|
||||
case 'processing': return 'blue';
|
||||
case 'error': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'Completado';
|
||||
case 'processing': return 'Procesando';
|
||||
case 'error': return 'Error';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleFiles(files);
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
handleFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = (files: File[]) => {
|
||||
console.log('Files selected:', files);
|
||||
// Simulate upload progress
|
||||
setIsProcessing(true);
|
||||
setUploadProgress(0);
|
||||
const interval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval);
|
||||
setIsProcessing(false);
|
||||
return 100;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const downloadTemplate = (template: string) => {
|
||||
console.log('Downloading template:', template);
|
||||
// Handle template download
|
||||
};
|
||||
|
||||
const retryUpload = (fileId: string) => {
|
||||
console.log('Retrying upload for file:', fileId);
|
||||
// Handle retry logic
|
||||
};
|
||||
|
||||
const deleteFile = (fileId: string) => {
|
||||
console.log('Deleting file:', fileId);
|
||||
// Handle delete logic
|
||||
};
|
||||
|
||||
const viewDetails = (fileId: string) => {
|
||||
console.log('Viewing details for file:', fileId);
|
||||
// Handle view details logic
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Carga de Datos"
|
||||
description="Importa tus datos existentes para acelerar la configuración inicial"
|
||||
/>
|
||||
|
||||
{/* Upload Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Archivos Subidos</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{uploadStats.totalFiles}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Upload className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completados</p>
|
||||
<p className="text-3xl font-bold text-green-600">{uploadStats.completedFiles}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Check className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Registros</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{uploadStats.totalRecords}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<File className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Errores</p>
|
||||
<p className="text-3xl font-bold text-red-600">{uploadStats.totalErrors}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Advertencias</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">{uploadStats.totalWarnings}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<Card className="p-8">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Arrastra archivos aquí o haz clic para seleccionar
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Formatos soportados: CSV, Excel (XLSX). Tamaño máximo: 10MB por archivo
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload">
|
||||
<Button className="cursor-pointer">
|
||||
Seleccionar Archivos
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="mt-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Procesando... {uploadProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Formatos Soportados</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{supportedFormats.map((format, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-900">{format.name}</h4>
|
||||
<div className="flex space-x-1">
|
||||
{format.formats.map((fmt, idx) => (
|
||||
<Badge key={idx} variant="blue" className="text-xs">{fmt}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-3">{format.description}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">Campos requeridos:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{format.requiredFields.map((field, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => downloadTemplate(format.template)}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-2" />
|
||||
Descargar Plantilla
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Uploaded Files */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Archivos Cargados</h3>
|
||||
<div className="space-y-3">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
{getStatusIcon(file.status)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">{file.name}</h4>
|
||||
<Badge variant={getStatusColor(file.status)}>
|
||||
{getStatusLabel(file.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-500">{file.size}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-600">
|
||||
<span>{file.records} registros</span>
|
||||
{file.errors > 0 && (
|
||||
<span className="text-red-600">{file.errors} errores</span>
|
||||
)}
|
||||
{file.warnings > 0 && (
|
||||
<span className="text-yellow-600">{file.warnings} advertencias</span>
|
||||
)}
|
||||
<span>Subido: {new Date(file.uploadedAt).toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
|
||||
{file.status === 'error' && file.errorMessage && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700">
|
||||
{file.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => viewDetails(file.id)}>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
{file.status === 'error' && (
|
||||
<Button size="sm" variant="outline" onClick={() => retryUpload(file.id)}>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => deleteFile(file.id)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Help Section */}
|
||||
<Card className="p-6 bg-blue-50 border-blue-200">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-4">Tips para la Carga de Datos</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-blue-800">
|
||||
<ul className="space-y-2">
|
||||
<li>• Usa las plantillas proporcionadas para garantizar el formato correcto</li>
|
||||
<li>• Verifica que todos los campos requeridos estén completos</li>
|
||||
<li>• Los archivos CSV deben usar codificación UTF-8</li>
|
||||
<li>• Las fechas deben estar en formato DD/MM/YYYY</li>
|
||||
</ul>
|
||||
<ul className="space-y-2">
|
||||
<li>• Los precios deben usar punto (.) como separador decimal</li>
|
||||
<li>• Evita caracteres especiales en los nombres de productos</li>
|
||||
<li>• Mantén los nombres de archivos descriptivos</li>
|
||||
<li>• Puedes cargar múltiples archivos del mismo tipo</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingUploadPage;
|
||||
1
frontend/src/pages/app/onboarding/upload/index.ts
Normal file
1
frontend/src/pages/app/onboarding/upload/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OnboardingUploadPage } from './OnboardingUploadPage';
|
||||
6
frontend/src/pages/app/operations/index.ts
Normal file
6
frontend/src/pages/app/operations/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './inventory';
|
||||
export * from './production';
|
||||
export * from './recipes';
|
||||
export * from './procurement';
|
||||
export * from './orders';
|
||||
export * from './pos';
|
||||
229
frontend/src/pages/app/operations/inventory/InventoryPage.tsx
Normal file
229
frontend/src/pages/app/operations/inventory/InventoryPage.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { InventoryTable, InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
|
||||
const mockInventoryItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Harina de Trigo',
|
||||
category: 'Harinas',
|
||||
currentStock: 45,
|
||||
minStock: 20,
|
||||
maxStock: 100,
|
||||
unit: 'kg',
|
||||
cost: 1.20,
|
||||
supplier: 'Molinos del Sur',
|
||||
lastRestocked: '2024-01-20',
|
||||
expirationDate: '2024-06-30',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levadura Fresca',
|
||||
category: 'Levaduras',
|
||||
currentStock: 8,
|
||||
minStock: 10,
|
||||
maxStock: 25,
|
||||
unit: 'kg',
|
||||
cost: 8.50,
|
||||
supplier: 'Levaduras SA',
|
||||
lastRestocked: '2024-01-25',
|
||||
expirationDate: '2024-02-15',
|
||||
status: 'low',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Mantequilla',
|
||||
category: 'Lácteos',
|
||||
currentStock: 15,
|
||||
minStock: 5,
|
||||
maxStock: 30,
|
||||
unit: 'kg',
|
||||
cost: 5.80,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-24',
|
||||
expirationDate: '2024-02-10',
|
||||
status: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
const lowStockItems = mockInventoryItems.filter(item => item.status === 'low');
|
||||
|
||||
const stats = {
|
||||
totalItems: mockInventoryItems.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
||||
needsReorder: lowStockItems.length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Inventario"
|
||||
description="Controla el stock de ingredientes y materias primas"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Artículo
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Artículos</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{stats.totalItems}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-[var(--color-info)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4-8-4m16 0v10l-8 4-8-4V7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Stock Bajo</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-error)]">{stats.lowStockItems}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Valor Total</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">€{stats.totalValue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-[var(--color-success)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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-1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Necesita Reorden</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{stats.needsReorder}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alert */}
|
||||
{lowStockItems.length > 0 && (
|
||||
<LowStockAlert items={lowStockItems} />
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar artículos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option value="Harinas">Harinas</option>
|
||||
<option value="Levaduras">Levaduras</option>
|
||||
<option value="Lácteos">Lácteos</option>
|
||||
<option value="Grasas">Grasas</option>
|
||||
<option value="Azúcares">Azúcares</option>
|
||||
<option value="Especias">Especias</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value="normal">Stock normal</option>
|
||||
<option value="low">Stock bajo</option>
|
||||
<option value="out">Sin stock</option>
|
||||
<option value="expired">Caducado</option>
|
||||
</select>
|
||||
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Más filtros
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Table */}
|
||||
<Card>
|
||||
<InventoryTable
|
||||
data={mockInventoryItems}
|
||||
onEdit={(item) => {
|
||||
setSelectedItem(item);
|
||||
setShowForm(true);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Form Modal */}
|
||||
{showForm && (
|
||||
<InventoryForm
|
||||
item={selectedItem}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
onSave={(item) => {
|
||||
// Handle save logic
|
||||
console.log('Saving item:', item);
|
||||
setShowForm(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { InventoryTable, InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
|
||||
const mockInventoryItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Harina de Trigo',
|
||||
category: 'Harinas',
|
||||
currentStock: 45,
|
||||
minStock: 20,
|
||||
maxStock: 100,
|
||||
unit: 'kg',
|
||||
cost: 1.20,
|
||||
supplier: 'Molinos del Sur',
|
||||
lastRestocked: '2024-01-20',
|
||||
expirationDate: '2024-06-30',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levadura Fresca',
|
||||
category: 'Levaduras',
|
||||
currentStock: 8,
|
||||
minStock: 10,
|
||||
maxStock: 25,
|
||||
unit: 'kg',
|
||||
cost: 8.50,
|
||||
supplier: 'Levaduras SA',
|
||||
lastRestocked: '2024-01-25',
|
||||
expirationDate: '2024-02-15',
|
||||
status: 'low',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Mantequilla',
|
||||
category: 'Lácteos',
|
||||
currentStock: 15,
|
||||
minStock: 5,
|
||||
maxStock: 30,
|
||||
unit: 'kg',
|
||||
cost: 5.80,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-24',
|
||||
expirationDate: '2024-02-10',
|
||||
status: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
const lowStockItems = mockInventoryItems.filter(item => item.status === 'low');
|
||||
|
||||
const stats = {
|
||||
totalItems: mockInventoryItems.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
||||
needsReorder: lowStockItems.length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Inventario"
|
||||
description="Controla el stock de ingredientes y materias primas"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Artículo
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Artículos</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalItems}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4-8-4m16 0v10l-8 4-8-4V7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Stock Bajo</p>
|
||||
<p className="text-3xl font-bold text-red-600">{stats.lowStockItems}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Valor Total</p>
|
||||
<p className="text-3xl font-bold text-green-600">€{stats.totalValue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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-1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Necesita Reorden</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{stats.needsReorder}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alert */}
|
||||
{lowStockItems.length > 0 && (
|
||||
<LowStockAlert items={lowStockItems} />
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar artículos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option value="Harinas">Harinas</option>
|
||||
<option value="Levaduras">Levaduras</option>
|
||||
<option value="Lácteos">Lácteos</option>
|
||||
<option value="Grasas">Grasas</option>
|
||||
<option value="Azúcares">Azúcares</option>
|
||||
<option value="Especias">Especias</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value="normal">Stock normal</option>
|
||||
<option value="low">Stock bajo</option>
|
||||
<option value="out">Sin stock</option>
|
||||
<option value="expired">Caducado</option>
|
||||
</select>
|
||||
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Más filtros
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Table */}
|
||||
<Card>
|
||||
<InventoryTable
|
||||
items={mockInventoryItems}
|
||||
searchTerm={searchTerm}
|
||||
filterCategory={filterCategory}
|
||||
filterStatus={filterStatus}
|
||||
onEdit={(item) => {
|
||||
setSelectedItem(item);
|
||||
setShowForm(true);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Form Modal */}
|
||||
{showForm && (
|
||||
<InventoryForm
|
||||
item={selectedItem}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
onSave={(item) => {
|
||||
// Handle save logic
|
||||
console.log('Saving item:', item);
|
||||
setShowForm(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
1
frontend/src/pages/app/operations/inventory/index.ts
Normal file
1
frontend/src/pages/app/operations/inventory/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as InventoryPage } from './InventoryPage';
|
||||
405
frontend/src/pages/app/operations/orders/OrdersPage.tsx
Normal file
405
frontend/src/pages/app/operations/orders/OrdersPage.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, Calendar, Clock, User, Package } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { OrdersTable, OrderForm } from '../../../../components/domain/sales';
|
||||
|
||||
const OrdersPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
|
||||
const mockOrders = [
|
||||
{
|
||||
id: 'ORD-2024-001',
|
||||
customerName: 'María García',
|
||||
customerEmail: 'maria@email.com',
|
||||
customerPhone: '+34 600 123 456',
|
||||
status: 'pending',
|
||||
orderDate: '2024-01-26T09:30:00Z',
|
||||
deliveryDate: '2024-01-26T16:00:00Z',
|
||||
items: [
|
||||
{ id: '1', name: 'Pan de Molde Integral', quantity: 2, price: 4.50, total: 9.00 },
|
||||
{ id: '2', name: 'Croissants de Mantequilla', quantity: 6, price: 1.50, total: 9.00 },
|
||||
],
|
||||
subtotal: 18.00,
|
||||
tax: 1.89,
|
||||
discount: 0,
|
||||
total: 19.89,
|
||||
paymentMethod: 'card',
|
||||
paymentStatus: 'pending',
|
||||
deliveryMethod: 'pickup',
|
||||
notes: 'Sin gluten por favor en el pan',
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-002',
|
||||
customerName: 'Juan Pérez',
|
||||
customerEmail: 'juan@email.com',
|
||||
customerPhone: '+34 600 654 321',
|
||||
status: 'completed',
|
||||
orderDate: '2024-01-25T14:15:00Z',
|
||||
deliveryDate: '2024-01-25T18:30:00Z',
|
||||
items: [
|
||||
{ id: '3', name: 'Tarta de Chocolate', quantity: 1, price: 25.00, total: 25.00 },
|
||||
{ id: '4', name: 'Magdalenas', quantity: 12, price: 0.75, total: 9.00 },
|
||||
],
|
||||
subtotal: 34.00,
|
||||
tax: 3.57,
|
||||
discount: 2.00,
|
||||
total: 35.57,
|
||||
paymentMethod: 'cash',
|
||||
paymentStatus: 'paid',
|
||||
deliveryMethod: 'delivery',
|
||||
notes: 'Cumpleaños - decoración especial',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-003',
|
||||
customerName: 'Ana Martínez',
|
||||
customerEmail: 'ana@email.com',
|
||||
customerPhone: '+34 600 987 654',
|
||||
status: 'in_progress',
|
||||
orderDate: '2024-01-26T07:45:00Z',
|
||||
deliveryDate: '2024-01-26T12:00:00Z',
|
||||
items: [
|
||||
{ id: '5', name: 'Baguettes Francesas', quantity: 4, price: 2.80, total: 11.20 },
|
||||
{ id: '6', name: 'Empanadas', quantity: 8, price: 2.50, total: 20.00 },
|
||||
],
|
||||
subtotal: 31.20,
|
||||
tax: 3.28,
|
||||
discount: 0,
|
||||
total: 34.48,
|
||||
paymentMethod: 'transfer',
|
||||
paymentStatus: 'paid',
|
||||
deliveryMethod: 'pickup',
|
||||
notes: '',
|
||||
priority: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
||||
ready: { color: 'green', text: 'Listo' },
|
||||
completed: { color: 'green', text: 'Completado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityConfig = {
|
||||
low: { color: 'gray', text: 'Baja' },
|
||||
normal: { color: 'blue', text: 'Normal' },
|
||||
high: { color: 'orange', text: 'Alta' },
|
||||
urgent: { color: 'red', text: 'Urgente' },
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || priority}</Badge>;
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
paid: { color: 'green', text: 'Pagado' },
|
||||
failed: { color: 'red', text: 'Fallido' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const filteredOrders = mockOrders.filter(order => {
|
||||
const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.customerEmail.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === 'all' || order.status === activeTab;
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: mockOrders.length,
|
||||
pending: mockOrders.filter(o => o.status === 'pending').length,
|
||||
inProgress: mockOrders.filter(o => o.status === 'in_progress').length,
|
||||
completed: mockOrders.filter(o => o.status === 'completed').length,
|
||||
totalRevenue: mockOrders.reduce((sum, order) => sum + order.total, 0),
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'Todos', count: stats.total },
|
||||
{ id: 'pending', label: 'Pendientes', count: stats.pending },
|
||||
{ id: 'in_progress', label: 'En Proceso', count: stats.inProgress },
|
||||
{ id: 'ready', label: 'Listos', count: 0 },
|
||||
{ id: 'completed', label: 'Completados', count: stats.completed },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Pedidos"
|
||||
description="Administra y controla todos los pedidos de tu panadería"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{stats.total}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Pendientes</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.pending}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">En Proceso</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.inProgress}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-[var(--color-info)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completados</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.completed}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-[var(--color-success)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ingresos</p>
|
||||
<p className="text-2xl font-bold text-purple-600">€{stats.totalRevenue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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-1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-[var(--border-primary)]">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${
|
||||
activeTab === tab.id
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className="ml-2 bg-[var(--bg-tertiary)] text-[var(--text-primary)] py-0.5 px-2.5 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar pedidos por cliente, ID o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Fecha
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Orders Table */}
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-[var(--bg-secondary)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Fecha Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Entrega
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-[var(--bg-secondary)]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{order.id}</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">{order.deliveryMethod === 'delivery' ? 'Entrega' : 'Recogida'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<User className="h-4 w-4 text-[var(--text-tertiary)] mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{order.customerName}</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">{order.customerEmail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPriorityBadge(order.priority)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{new Date(order.orderDate).toLocaleDateString('es-ES')}
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{new Date(order.orderDate).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{new Date(order.deliveryDate).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">€{order.total.toFixed(2)}</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">{order.items.length} artículos</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(order.paymentStatus)}
|
||||
<div className="text-xs text-[var(--text-tertiary)] capitalize">{order.paymentMethod}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedOrder(order);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Order Form Modal */}
|
||||
{showForm && (
|
||||
<OrderForm
|
||||
order={selectedOrder}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
onSave={(order) => {
|
||||
// Handle save logic
|
||||
console.log('Saving order:', order);
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
405
frontend/src/pages/app/operations/orders/OrdersPage.tsx.backup
Normal file
405
frontend/src/pages/app/operations/orders/OrdersPage.tsx.backup
Normal file
@@ -0,0 +1,405 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, Calendar, Clock, User, Package } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { OrdersTable, OrderForm } from '../../../../components/domain/sales';
|
||||
|
||||
const OrdersPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
|
||||
const mockOrders = [
|
||||
{
|
||||
id: 'ORD-2024-001',
|
||||
customerName: 'María García',
|
||||
customerEmail: 'maria@email.com',
|
||||
customerPhone: '+34 600 123 456',
|
||||
status: 'pending',
|
||||
orderDate: '2024-01-26T09:30:00Z',
|
||||
deliveryDate: '2024-01-26T16:00:00Z',
|
||||
items: [
|
||||
{ id: '1', name: 'Pan de Molde Integral', quantity: 2, price: 4.50, total: 9.00 },
|
||||
{ id: '2', name: 'Croissants de Mantequilla', quantity: 6, price: 1.50, total: 9.00 },
|
||||
],
|
||||
subtotal: 18.00,
|
||||
tax: 1.89,
|
||||
discount: 0,
|
||||
total: 19.89,
|
||||
paymentMethod: 'card',
|
||||
paymentStatus: 'pending',
|
||||
deliveryMethod: 'pickup',
|
||||
notes: 'Sin gluten por favor en el pan',
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-002',
|
||||
customerName: 'Juan Pérez',
|
||||
customerEmail: 'juan@email.com',
|
||||
customerPhone: '+34 600 654 321',
|
||||
status: 'completed',
|
||||
orderDate: '2024-01-25T14:15:00Z',
|
||||
deliveryDate: '2024-01-25T18:30:00Z',
|
||||
items: [
|
||||
{ id: '3', name: 'Tarta de Chocolate', quantity: 1, price: 25.00, total: 25.00 },
|
||||
{ id: '4', name: 'Magdalenas', quantity: 12, price: 0.75, total: 9.00 },
|
||||
],
|
||||
subtotal: 34.00,
|
||||
tax: 3.57,
|
||||
discount: 2.00,
|
||||
total: 35.57,
|
||||
paymentMethod: 'cash',
|
||||
paymentStatus: 'paid',
|
||||
deliveryMethod: 'delivery',
|
||||
notes: 'Cumpleaños - decoración especial',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-003',
|
||||
customerName: 'Ana Martínez',
|
||||
customerEmail: 'ana@email.com',
|
||||
customerPhone: '+34 600 987 654',
|
||||
status: 'in_progress',
|
||||
orderDate: '2024-01-26T07:45:00Z',
|
||||
deliveryDate: '2024-01-26T12:00:00Z',
|
||||
items: [
|
||||
{ id: '5', name: 'Baguettes Francesas', quantity: 4, price: 2.80, total: 11.20 },
|
||||
{ id: '6', name: 'Empanadas', quantity: 8, price: 2.50, total: 20.00 },
|
||||
],
|
||||
subtotal: 31.20,
|
||||
tax: 3.28,
|
||||
discount: 0,
|
||||
total: 34.48,
|
||||
paymentMethod: 'transfer',
|
||||
paymentStatus: 'paid',
|
||||
deliveryMethod: 'pickup',
|
||||
notes: '',
|
||||
priority: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
||||
ready: { color: 'green', text: 'Listo' },
|
||||
completed: { color: 'green', text: 'Completado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityConfig = {
|
||||
low: { color: 'gray', text: 'Baja' },
|
||||
normal: { color: 'blue', text: 'Normal' },
|
||||
high: { color: 'orange', text: 'Alta' },
|
||||
urgent: { color: 'red', text: 'Urgente' },
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || priority}</Badge>;
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
paid: { color: 'green', text: 'Pagado' },
|
||||
failed: { color: 'red', text: 'Fallido' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const filteredOrders = mockOrders.filter(order => {
|
||||
const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.customerEmail.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === 'all' || order.status === activeTab;
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: mockOrders.length,
|
||||
pending: mockOrders.filter(o => o.status === 'pending').length,
|
||||
inProgress: mockOrders.filter(o => o.status === 'in_progress').length,
|
||||
completed: mockOrders.filter(o => o.status === 'completed').length,
|
||||
totalRevenue: mockOrders.reduce((sum, order) => sum + order.total, 0),
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'Todos', count: stats.total },
|
||||
{ id: 'pending', label: 'Pendientes', count: stats.pending },
|
||||
{ id: 'in_progress', label: 'En Proceso', count: stats.inProgress },
|
||||
{ id: 'ready', label: 'Listos', count: 0 },
|
||||
{ id: 'completed', label: 'Completados', count: stats.completed },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Pedidos"
|
||||
description="Administra y controla todos los pedidos de tu panadería"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pendientes</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{stats.pending}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">En Proceso</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.inProgress}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completados</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos</p>
|
||||
<p className="text-2xl font-bold text-purple-600">€{stats.totalRevenue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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-1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${
|
||||
activeTab === tab.id
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className="ml-2 bg-gray-100 text-gray-900 py-0.5 px-2.5 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar pedidos por cliente, ID o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Fecha
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Orders Table */}
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fecha Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Entrega
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{order.id}</div>
|
||||
<div className="text-xs text-gray-500">{order.deliveryMethod === 'delivery' ? 'Entrega' : 'Recogida'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<User className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{order.customerName}</div>
|
||||
<div className="text-xs text-gray-500">{order.customerEmail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPriorityBadge(order.priority)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.orderDate).toLocaleDateString('es-ES')}
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(order.orderDate).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(order.deliveryDate).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">€{order.total.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{order.items.length} artículos</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(order.paymentStatus)}
|
||||
<div className="text-xs text-gray-500 capitalize">{order.paymentMethod}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedOrder(order);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Order Form Modal */}
|
||||
{showForm && (
|
||||
<OrderForm
|
||||
order={selectedOrder}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
onSave={(order) => {
|
||||
// Handle save logic
|
||||
console.log('Saving order:', order);
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
1
frontend/src/pages/app/operations/orders/index.ts
Normal file
1
frontend/src/pages/app/operations/orders/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OrdersPage } from './OrdersPage';
|
||||
368
frontend/src/pages/app/operations/pos/POSPage.tsx
Normal file
368
frontend/src/pages/app/operations/pos/POSPage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const POSPage: React.FC = () => {
|
||||
const [cart, setCart] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category: string;
|
||||
}>>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
||||
const [cashReceived, setCashReceived] = useState('');
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
price: 4.50,
|
||||
category: 'bread',
|
||||
stock: 25,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
price: 1.50,
|
||||
category: 'pastry',
|
||||
stock: 32,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Baguette Francesa',
|
||||
price: 2.80,
|
||||
category: 'bread',
|
||||
stock: 18,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Tarta de Chocolate',
|
||||
price: 25.00,
|
||||
category: 'cake',
|
||||
stock: 8,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
price: 0.75,
|
||||
category: 'pastry',
|
||||
stock: 48,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Empanadas',
|
||||
price: 2.50,
|
||||
category: 'other',
|
||||
stock: 24,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
{ id: 'other', name: 'Otros' },
|
||||
];
|
||||
|
||||
const filteredProducts = products.filter(product =>
|
||||
selectedCategory === 'all' || product.category === selectedCategory
|
||||
);
|
||||
|
||||
const addToCart = (product: typeof products[0]) => {
|
||||
setCart(prevCart => {
|
||||
const existingItem = prevCart.find(item => item.id === product.id);
|
||||
if (existingItem) {
|
||||
return prevCart.map(item =>
|
||||
item.id === product.id
|
||||
? { ...item, quantity: item.quantity + 1 }
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
return [...prevCart, {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
category: product.category,
|
||||
}];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuantity = (id: string, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
||||
} else {
|
||||
setCart(prevCart =>
|
||||
prevCart.map(item =>
|
||||
item.id === id ? { ...item, quantity } : item
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCart = () => {
|
||||
setCart([]);
|
||||
};
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const taxRate = 0.21; // 21% IVA
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0;
|
||||
|
||||
const processPayment = () => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
// Process payment logic here
|
||||
console.log('Processing payment:', {
|
||||
cart,
|
||||
customerInfo,
|
||||
paymentMethod,
|
||||
total,
|
||||
cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined,
|
||||
change: paymentMethod === 'cash' ? change : undefined,
|
||||
});
|
||||
|
||||
// Clear cart after successful payment
|
||||
setCart([]);
|
||||
setCustomerInfo({ name: '', email: '', phone: '' });
|
||||
setCashReceived('');
|
||||
|
||||
alert('Venta procesada exitosamente');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<PageHeader
|
||||
title="Punto de Venta"
|
||||
description="Sistema de ventas integrado"
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
||||
{/* Products Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Categories */}
|
||||
<div className="flex space-x-2 overflow-x-auto">
|
||||
{categories.map(category => (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategory === category.id ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredProducts.map(product => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-20 object-cover rounded mb-3"
|
||||
/>
|
||||
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3>
|
||||
<p className="text-lg font-bold text-[var(--color-success)]">€{product.price.toFixed(2)}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Stock: {product.stock}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cart and Checkout Section */}
|
||||
<div className="space-y-6">
|
||||
{/* Cart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||
Carrito ({cart.length})
|
||||
</h3>
|
||||
{cart.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearCart}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{cart.length === 0 ? (
|
||||
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
|
||||
) : (
|
||||
cart.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">{item.name}</h4>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">€{item.price.toFixed(2)} c/u</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuantity(item.id, item.quantity - 1);
|
||||
}}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuantity(item.id, item.quantity + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
<p className="text-sm font-medium">€{(item.price * item.quantity).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cart.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal:</span>
|
||||
<span>€{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>IVA (21%):</span>
|
||||
<span>€{tax.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
||||
<span>Total:</span>
|
||||
<span>€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Customer Info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<User className="w-5 h-5 mr-2" />
|
||||
Cliente (Opcional)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Nombre"
|
||||
value={customerInfo.name}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Teléfono"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Calculator className="w-5 h-5 mr-2" />
|
||||
Método de Pago
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={paymentMethod === 'cash' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('cash')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Banknote className="w-4 h-4 mr-1" />
|
||||
Efectivo
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'card' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-1" />
|
||||
Tarjeta
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'transfer' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('transfer')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
Transferencia
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paymentMethod === 'cash' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Efectivo recibido"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={cashReceived}
|
||||
onChange={(e) => setCashReceived(e.target.value)}
|
||||
/>
|
||||
{cashReceived && parseFloat(cashReceived) >= total && (
|
||||
<div className="p-2 bg-green-50 rounded text-center">
|
||||
<p className="text-sm text-[var(--color-success)]">
|
||||
Cambio: <span className="font-bold">€{change.toFixed(2)}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={processPayment}
|
||||
disabled={cart.length === 0 || (paymentMethod === 'cash' && (!cashReceived || parseFloat(cashReceived) < total))}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Receipt className="w-5 h-5 mr-2" />
|
||||
Procesar Venta - €{total.toFixed(2)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default POSPage;
|
||||
368
frontend/src/pages/app/operations/pos/POSPage.tsx.backup
Normal file
368
frontend/src/pages/app/operations/pos/POSPage.tsx.backup
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const POSPage: React.FC = () => {
|
||||
const [cart, setCart] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category: string;
|
||||
}>>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
||||
const [cashReceived, setCashReceived] = useState('');
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
price: 4.50,
|
||||
category: 'bread',
|
||||
stock: 25,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
price: 1.50,
|
||||
category: 'pastry',
|
||||
stock: 32,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Baguette Francesa',
|
||||
price: 2.80,
|
||||
category: 'bread',
|
||||
stock: 18,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Tarta de Chocolate',
|
||||
price: 25.00,
|
||||
category: 'cake',
|
||||
stock: 8,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
price: 0.75,
|
||||
category: 'pastry',
|
||||
stock: 48,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Empanadas',
|
||||
price: 2.50,
|
||||
category: 'other',
|
||||
stock: 24,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
{ id: 'other', name: 'Otros' },
|
||||
];
|
||||
|
||||
const filteredProducts = products.filter(product =>
|
||||
selectedCategory === 'all' || product.category === selectedCategory
|
||||
);
|
||||
|
||||
const addToCart = (product: typeof products[0]) => {
|
||||
setCart(prevCart => {
|
||||
const existingItem = prevCart.find(item => item.id === product.id);
|
||||
if (existingItem) {
|
||||
return prevCart.map(item =>
|
||||
item.id === product.id
|
||||
? { ...item, quantity: item.quantity + 1 }
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
return [...prevCart, {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
category: product.category,
|
||||
}];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuantity = (id: string, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
||||
} else {
|
||||
setCart(prevCart =>
|
||||
prevCart.map(item =>
|
||||
item.id === id ? { ...item, quantity } : item
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCart = () => {
|
||||
setCart([]);
|
||||
};
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const taxRate = 0.21; // 21% IVA
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0;
|
||||
|
||||
const processPayment = () => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
// Process payment logic here
|
||||
console.log('Processing payment:', {
|
||||
cart,
|
||||
customerInfo,
|
||||
paymentMethod,
|
||||
total,
|
||||
cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined,
|
||||
change: paymentMethod === 'cash' ? change : undefined,
|
||||
});
|
||||
|
||||
// Clear cart after successful payment
|
||||
setCart([]);
|
||||
setCustomerInfo({ name: '', email: '', phone: '' });
|
||||
setCashReceived('');
|
||||
|
||||
alert('Venta procesada exitosamente');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<PageHeader
|
||||
title="Punto de Venta"
|
||||
description="Sistema de ventas integrado"
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
||||
{/* Products Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Categories */}
|
||||
<div className="flex space-x-2 overflow-x-auto">
|
||||
{categories.map(category => (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategory === category.id ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredProducts.map(product => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-20 object-cover rounded mb-3"
|
||||
/>
|
||||
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3>
|
||||
<p className="text-lg font-bold text-green-600">€{product.price.toFixed(2)}</p>
|
||||
<p className="text-xs text-gray-500">Stock: {product.stock}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cart and Checkout Section */}
|
||||
<div className="space-y-6">
|
||||
{/* Cart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||
Carrito ({cart.length})
|
||||
</h3>
|
||||
{cart.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearCart}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{cart.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">Carrito vacío</p>
|
||||
) : (
|
||||
cart.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">{item.name}</h4>
|
||||
<p className="text-xs text-gray-500">€{item.price.toFixed(2)} c/u</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuantity(item.id, item.quantity - 1);
|
||||
}}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuantity(item.id, item.quantity + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
<p className="text-sm font-medium">€{(item.price * item.quantity).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cart.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal:</span>
|
||||
<span>€{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>IVA (21%):</span>
|
||||
<span>€{tax.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
||||
<span>Total:</span>
|
||||
<span>€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Customer Info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<User className="w-5 h-5 mr-2" />
|
||||
Cliente (Opcional)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Nombre"
|
||||
value={customerInfo.name}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Teléfono"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Calculator className="w-5 h-5 mr-2" />
|
||||
Método de Pago
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={paymentMethod === 'cash' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('cash')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Banknote className="w-4 h-4 mr-1" />
|
||||
Efectivo
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'card' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-1" />
|
||||
Tarjeta
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'transfer' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('transfer')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
Transferencia
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paymentMethod === 'cash' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Efectivo recibido"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={cashReceived}
|
||||
onChange={(e) => setCashReceived(e.target.value)}
|
||||
/>
|
||||
{cashReceived && parseFloat(cashReceived) >= total && (
|
||||
<div className="p-2 bg-green-50 rounded text-center">
|
||||
<p className="text-sm text-green-600">
|
||||
Cambio: <span className="font-bold">€{change.toFixed(2)}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={processPayment}
|
||||
disabled={cart.length === 0 || (paymentMethod === 'cash' && (!cashReceived || parseFloat(cashReceived) < total))}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Receipt className="w-5 h-5 mr-2" />
|
||||
Procesar Venta - €{total.toFixed(2)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default POSPage;
|
||||
@@ -0,0 +1,449 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, ShoppingCart, Truck, DollarSign, Calendar } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const ProcurementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('orders');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const mockPurchaseOrders = [
|
||||
{
|
||||
id: 'PO-2024-001',
|
||||
supplier: 'Molinos del Sur',
|
||||
status: 'pending',
|
||||
orderDate: '2024-01-25',
|
||||
deliveryDate: '2024-01-28',
|
||||
totalAmount: 1250.00,
|
||||
items: [
|
||||
{ name: 'Harina de Trigo', quantity: 50, unit: 'kg', price: 1.20, total: 60.00 },
|
||||
{ name: 'Harina Integral', quantity: 100, unit: 'kg', price: 1.30, total: 130.00 },
|
||||
],
|
||||
paymentStatus: 'pending',
|
||||
notes: 'Entrega en horario de mañana',
|
||||
},
|
||||
{
|
||||
id: 'PO-2024-002',
|
||||
supplier: 'Levaduras SA',
|
||||
status: 'delivered',
|
||||
orderDate: '2024-01-20',
|
||||
deliveryDate: '2024-01-23',
|
||||
totalAmount: 425.50,
|
||||
items: [
|
||||
{ name: 'Levadura Fresca', quantity: 5, unit: 'kg', price: 8.50, total: 42.50 },
|
||||
{ name: 'Mejorante', quantity: 10, unit: 'kg', price: 12.30, total: 123.00 },
|
||||
],
|
||||
paymentStatus: 'paid',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: 'PO-2024-003',
|
||||
supplier: 'Lácteos Frescos',
|
||||
status: 'in_transit',
|
||||
orderDate: '2024-01-24',
|
||||
deliveryDate: '2024-01-26',
|
||||
totalAmount: 320.75,
|
||||
items: [
|
||||
{ name: 'Mantequilla', quantity: 20, unit: 'kg', price: 5.80, total: 116.00 },
|
||||
{ name: 'Nata', quantity: 15, unit: 'L', price: 3.25, total: 48.75 },
|
||||
],
|
||||
paymentStatus: 'pending',
|
||||
notes: 'Producto refrigerado',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Molinos del Sur',
|
||||
contact: 'Juan Pérez',
|
||||
email: 'juan@molinosdelsur.com',
|
||||
phone: '+34 91 234 5678',
|
||||
category: 'Harinas',
|
||||
rating: 4.8,
|
||||
totalOrders: 24,
|
||||
totalSpent: 15600.00,
|
||||
paymentTerms: '30 días',
|
||||
leadTime: '2-3 días',
|
||||
location: 'Sevilla',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levaduras SA',
|
||||
contact: 'María González',
|
||||
email: 'maria@levaduras.com',
|
||||
phone: '+34 93 456 7890',
|
||||
category: 'Levaduras',
|
||||
rating: 4.6,
|
||||
totalOrders: 18,
|
||||
totalSpent: 8450.00,
|
||||
paymentTerms: '15 días',
|
||||
leadTime: '1-2 días',
|
||||
location: 'Barcelona',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Lácteos Frescos',
|
||||
contact: 'Carlos Ruiz',
|
||||
email: 'carlos@lacteosfrescos.com',
|
||||
phone: '+34 96 789 0123',
|
||||
category: 'Lácteos',
|
||||
rating: 4.4,
|
||||
totalOrders: 32,
|
||||
totalSpent: 12300.00,
|
||||
paymentTerms: '20 días',
|
||||
leadTime: '1 día',
|
||||
location: 'Valencia',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
approved: { color: 'blue', text: 'Aprobado' },
|
||||
in_transit: { color: 'purple', text: 'En Tránsito' },
|
||||
delivered: { color: 'green', text: 'Entregado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
paid: { color: 'green', text: 'Pagado' },
|
||||
overdue: { color: 'red', text: 'Vencido' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
totalOrders: mockPurchaseOrders.length,
|
||||
pendingOrders: mockPurchaseOrders.filter(o => o.status === 'pending').length,
|
||||
totalSpent: mockPurchaseOrders.reduce((sum, order) => sum + order.totalAmount, 0),
|
||||
activeSuppliers: mockSuppliers.filter(s => s.status === 'active').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Compras"
|
||||
description="Administra órdenes de compra, proveedores y seguimiento de entregas"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Compra
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Órdenes Totales</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{stats.totalOrders}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-12 w-12 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Órdenes Pendientes</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{stats.pendingOrders}</p>
|
||||
</div>
|
||||
<Calendar className="h-12 w-12 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Gasto Total</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">€{stats.totalSpent.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="h-12 w-12 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Proveedores Activos</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{stats.activeSuppliers}</p>
|
||||
</div>
|
||||
<Truck className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-[var(--border-primary)]">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'orders'
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
Órdenes de Compra
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suppliers')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'suppliers'
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
Proveedores
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'analytics'
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
Análisis
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder={`Buscar ${activeTab === 'orders' ? 'órdenes' : 'proveedores'}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'orders' && (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-[var(--bg-secondary)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Orden
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Proveedor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Fecha Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Fecha Entrega
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Monto Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockPurchaseOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-[var(--bg-secondary)]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{order.id}</div>
|
||||
{order.notes && (
|
||||
<div className="text-xs text-[var(--text-tertiary)]">{order.notes}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{order.supplier}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{new Date(order.orderDate).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-[var(--text-primary)]">
|
||||
€{order.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(order.paymentStatus)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">Ver</Button>
|
||||
<Button variant="outline" size="sm">Editar</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'suppliers' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{mockSuppliers.map((supplier) => (
|
||||
<Card key={supplier.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{supplier.name}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{supplier.category}</p>
|
||||
</div>
|
||||
<Badge variant="green">Activo</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Contacto:</span>
|
||||
<span className="font-medium">{supplier.contact}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Email:</span>
|
||||
<span className="font-medium">{supplier.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Teléfono:</span>
|
||||
<span className="font-medium">{supplier.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Ubicación:</span>
|
||||
<span className="font-medium">{supplier.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Valoración</p>
|
||||
<p className="text-sm font-medium flex items-center">
|
||||
<span className="text-yellow-500">★</span>
|
||||
<span className="ml-1">{supplier.rating}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Pedidos</p>
|
||||
<p className="text-sm font-medium">{supplier.totalOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Total Gastado</p>
|
||||
<p className="text-sm font-medium">€{supplier.totalSpent.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Tiempo Entrega</p>
|
||||
<p className="text-sm font-medium">{supplier.leadTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-[var(--text-secondary)]">Condiciones de Pago</p>
|
||||
<p className="text-sm font-medium">{supplier.paymentTerms}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Ver Detalles
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1">
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Gastos por Mes</h3>
|
||||
<div className="h-64 flex items-center justify-center bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-[var(--text-tertiary)]">Gráfico de gastos mensuales</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Top Proveedores</h3>
|
||||
<div className="space-y-3">
|
||||
{mockSuppliers
|
||||
.sort((a, b) => b.totalSpent - a.totalSpent)
|
||||
.slice(0, 5)
|
||||
.map((supplier, index) => (
|
||||
<div key={supplier.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-[var(--text-tertiary)] w-4">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-[var(--text-primary)]">{supplier.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
€{supplier.totalSpent.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Gastos por Categoría</h3>
|
||||
<div className="h-64 flex items-center justify-center bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-[var(--text-tertiary)]">Gráfico de gastos por categoría</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcurementPage;
|
||||
@@ -0,0 +1,449 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, ShoppingCart, Truck, DollarSign, Calendar } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const ProcurementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('orders');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const mockPurchaseOrders = [
|
||||
{
|
||||
id: 'PO-2024-001',
|
||||
supplier: 'Molinos del Sur',
|
||||
status: 'pending',
|
||||
orderDate: '2024-01-25',
|
||||
deliveryDate: '2024-01-28',
|
||||
totalAmount: 1250.00,
|
||||
items: [
|
||||
{ name: 'Harina de Trigo', quantity: 50, unit: 'kg', price: 1.20, total: 60.00 },
|
||||
{ name: 'Harina Integral', quantity: 100, unit: 'kg', price: 1.30, total: 130.00 },
|
||||
],
|
||||
paymentStatus: 'pending',
|
||||
notes: 'Entrega en horario de mañana',
|
||||
},
|
||||
{
|
||||
id: 'PO-2024-002',
|
||||
supplier: 'Levaduras SA',
|
||||
status: 'delivered',
|
||||
orderDate: '2024-01-20',
|
||||
deliveryDate: '2024-01-23',
|
||||
totalAmount: 425.50,
|
||||
items: [
|
||||
{ name: 'Levadura Fresca', quantity: 5, unit: 'kg', price: 8.50, total: 42.50 },
|
||||
{ name: 'Mejorante', quantity: 10, unit: 'kg', price: 12.30, total: 123.00 },
|
||||
],
|
||||
paymentStatus: 'paid',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: 'PO-2024-003',
|
||||
supplier: 'Lácteos Frescos',
|
||||
status: 'in_transit',
|
||||
orderDate: '2024-01-24',
|
||||
deliveryDate: '2024-01-26',
|
||||
totalAmount: 320.75,
|
||||
items: [
|
||||
{ name: 'Mantequilla', quantity: 20, unit: 'kg', price: 5.80, total: 116.00 },
|
||||
{ name: 'Nata', quantity: 15, unit: 'L', price: 3.25, total: 48.75 },
|
||||
],
|
||||
paymentStatus: 'pending',
|
||||
notes: 'Producto refrigerado',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Molinos del Sur',
|
||||
contact: 'Juan Pérez',
|
||||
email: 'juan@molinosdelsur.com',
|
||||
phone: '+34 91 234 5678',
|
||||
category: 'Harinas',
|
||||
rating: 4.8,
|
||||
totalOrders: 24,
|
||||
totalSpent: 15600.00,
|
||||
paymentTerms: '30 días',
|
||||
leadTime: '2-3 días',
|
||||
location: 'Sevilla',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levaduras SA',
|
||||
contact: 'María González',
|
||||
email: 'maria@levaduras.com',
|
||||
phone: '+34 93 456 7890',
|
||||
category: 'Levaduras',
|
||||
rating: 4.6,
|
||||
totalOrders: 18,
|
||||
totalSpent: 8450.00,
|
||||
paymentTerms: '15 días',
|
||||
leadTime: '1-2 días',
|
||||
location: 'Barcelona',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Lácteos Frescos',
|
||||
contact: 'Carlos Ruiz',
|
||||
email: 'carlos@lacteosfrescos.com',
|
||||
phone: '+34 96 789 0123',
|
||||
category: 'Lácteos',
|
||||
rating: 4.4,
|
||||
totalOrders: 32,
|
||||
totalSpent: 12300.00,
|
||||
paymentTerms: '20 días',
|
||||
leadTime: '1 día',
|
||||
location: 'Valencia',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
approved: { color: 'blue', text: 'Aprobado' },
|
||||
in_transit: { color: 'purple', text: 'En Tránsito' },
|
||||
delivered: { color: 'green', text: 'Entregado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
paid: { color: 'green', text: 'Pagado' },
|
||||
overdue: { color: 'red', text: 'Vencido' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
totalOrders: mockPurchaseOrders.length,
|
||||
pendingOrders: mockPurchaseOrders.filter(o => o.status === 'pending').length,
|
||||
totalSpent: mockPurchaseOrders.reduce((sum, order) => sum + order.totalAmount, 0),
|
||||
activeSuppliers: mockSuppliers.filter(s => s.status === 'active').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Compras"
|
||||
description="Administra órdenes de compra, proveedores y seguimiento de entregas"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Compra
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Órdenes Totales</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalOrders}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-12 w-12 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Órdenes Pendientes</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{stats.pendingOrders}</p>
|
||||
</div>
|
||||
<Calendar className="h-12 w-12 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Gasto Total</p>
|
||||
<p className="text-3xl font-bold text-green-600">€{stats.totalSpent.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Proveedores Activos</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{stats.activeSuppliers}</p>
|
||||
</div>
|
||||
<Truck className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'orders'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Órdenes de Compra
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suppliers')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'suppliers'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Proveedores
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'analytics'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Análisis
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={`Buscar ${activeTab === 'orders' ? 'órdenes' : 'proveedores'}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'orders' && (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Orden
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Proveedor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fecha Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fecha Entrega
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Monto Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockPurchaseOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{order.id}</div>
|
||||
{order.notes && (
|
||||
<div className="text-xs text-gray-500">{order.notes}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{order.supplier}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.orderDate).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
€{order.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(order.paymentStatus)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">Ver</Button>
|
||||
<Button variant="outline" size="sm">Editar</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'suppliers' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{mockSuppliers.map((supplier) => (
|
||||
<Card key={supplier.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{supplier.name}</h3>
|
||||
<p className="text-sm text-gray-600">{supplier.category}</p>
|
||||
</div>
|
||||
<Badge variant="green">Activo</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Contacto:</span>
|
||||
<span className="font-medium">{supplier.contact}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Email:</span>
|
||||
<span className="font-medium">{supplier.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Teléfono:</span>
|
||||
<span className="font-medium">{supplier.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Ubicación:</span>
|
||||
<span className="font-medium">{supplier.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Valoración</p>
|
||||
<p className="text-sm font-medium flex items-center">
|
||||
<span className="text-yellow-500">★</span>
|
||||
<span className="ml-1">{supplier.rating}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Pedidos</p>
|
||||
<p className="text-sm font-medium">{supplier.totalOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Total Gastado</p>
|
||||
<p className="text-sm font-medium">€{supplier.totalSpent.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Tiempo Entrega</p>
|
||||
<p className="text-sm font-medium">{supplier.leadTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-600">Condiciones de Pago</p>
|
||||
<p className="text-sm font-medium">{supplier.paymentTerms}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Ver Detalles
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1">
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gastos por Mes</h3>
|
||||
<div className="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">Gráfico de gastos mensuales</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Proveedores</h3>
|
||||
<div className="space-y-3">
|
||||
{mockSuppliers
|
||||
.sort((a, b) => b.totalSpent - a.totalSpent)
|
||||
.slice(0, 5)
|
||||
.map((supplier, index) => (
|
||||
<div key={supplier.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-500 w-4">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-gray-900">{supplier.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
€{supplier.totalSpent.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gastos por Categoría</h3>
|
||||
<div className="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">Gráfico de gastos por categoría</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcurementPage;
|
||||
315
frontend/src/pages/app/operations/production/ProductionPage.tsx
Normal file
315
frontend/src/pages/app/operations/production/ProductionPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Calendar, Clock, Users, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('schedule');
|
||||
|
||||
const mockProductionStats = {
|
||||
dailyTarget: 150,
|
||||
completed: 85,
|
||||
inProgress: 12,
|
||||
pending: 53,
|
||||
efficiency: 78,
|
||||
quality: 94,
|
||||
};
|
||||
|
||||
const mockProductionOrders = [
|
||||
{
|
||||
id: '1',
|
||||
recipeName: 'Pan de Molde Integral',
|
||||
quantity: 20,
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assignedTo: 'Juan Panadero',
|
||||
startTime: '2024-01-26T06:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T10:00:00Z',
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
recipeName: 'Croissants de Mantequilla',
|
||||
quantity: 50,
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
assignedTo: 'María González',
|
||||
startTime: '2024-01-26T08:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T12:00:00Z',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
recipeName: 'Baguettes Francesas',
|
||||
quantity: 30,
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
assignedTo: 'Carlos Ruiz',
|
||||
startTime: '2024-01-26T04:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T08:00:00Z',
|
||||
progress: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
||||
completed: { color: 'green', text: 'Completado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityConfig = {
|
||||
low: { color: 'gray', text: 'Baja' },
|
||||
medium: { color: 'yellow', text: 'Media' },
|
||||
high: { color: 'orange', text: 'Alta' },
|
||||
urgent: { color: 'red', text: 'Urgente' },
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Production Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Meta Diaria</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{mockProductionStats.dailyTarget}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completado</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">{mockProductionStats.completed}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-[var(--color-success)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">En Proceso</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{mockProductionStats.inProgress}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Pendiente</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{mockProductionStats.pending}</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Eficiencia</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{mockProductionStats.efficiency}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Calidad</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{mockProductionStats.quality}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-[var(--border-primary)]">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'schedule'
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
Programación
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('batches')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'batches'
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
Lotes de Producción
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('quality')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'quality'
|
||||
? 'border-orange-500 text-[var(--color-primary)]'
|
||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
Control de Calidad
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">Órdenes de Producción</h3>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Vista Calendario
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-[var(--bg-secondary)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Receta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Cantidad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Asignado a
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Progreso
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Tiempo Estimado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockProductionOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-[var(--bg-secondary)]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{order.recipeName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{order.quantity} unidades
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPriorityBadge(order.priority)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 text-[var(--text-tertiary)] mr-2" />
|
||||
<span className="text-sm text-[var(--text-primary)]">{order.assignedTo}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${order.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-primary)]">{order.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Button variant="outline" size="sm" className="mr-2">
|
||||
Ver
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Editar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
<BatchTracker />
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<QualityControl />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPage;
|
||||
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Calendar, Clock, Users, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('schedule');
|
||||
|
||||
const mockProductionStats = {
|
||||
dailyTarget: 150,
|
||||
completed: 85,
|
||||
inProgress: 12,
|
||||
pending: 53,
|
||||
efficiency: 78,
|
||||
quality: 94,
|
||||
};
|
||||
|
||||
const mockProductionOrders = [
|
||||
{
|
||||
id: '1',
|
||||
recipeName: 'Pan de Molde Integral',
|
||||
quantity: 20,
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assignedTo: 'Juan Panadero',
|
||||
startTime: '2024-01-26T06:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T10:00:00Z',
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
recipeName: 'Croissants de Mantequilla',
|
||||
quantity: 50,
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
assignedTo: 'María González',
|
||||
startTime: '2024-01-26T08:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T12:00:00Z',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
recipeName: 'Baguettes Francesas',
|
||||
quantity: 30,
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
assignedTo: 'Carlos Ruiz',
|
||||
startTime: '2024-01-26T04:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T08:00:00Z',
|
||||
progress: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
||||
completed: { color: 'green', text: 'Completado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityConfig = {
|
||||
low: { color: 'gray', text: 'Baja' },
|
||||
medium: { color: 'yellow', text: 'Media' },
|
||||
high: { color: 'orange', text: 'Alta' },
|
||||
urgent: { color: 'red', text: 'Urgente' },
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Production Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Meta Diaria</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{mockProductionStats.dailyTarget}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completado</p>
|
||||
<p className="text-2xl font-bold text-green-600">{mockProductionStats.completed}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">En Proceso</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{mockProductionStats.inProgress}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pendiente</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{mockProductionStats.pending}</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Eficiencia</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{mockProductionStats.efficiency}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Calidad</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{mockProductionStats.quality}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'schedule'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Programación
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('batches')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'batches'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Lotes de Producción
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('quality')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'quality'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Control de Calidad
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">Órdenes de Producción</h3>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Vista Calendario
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Receta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Cantidad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Asignado a
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Progreso
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tiempo Estimado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockProductionOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{order.recipeName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{order.quantity} unidades
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPriorityBadge(order.priority)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-900">{order.assignedTo}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${order.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900">{order.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Button variant="outline" size="sm" className="mr-2">
|
||||
Ver
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Editar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
<BatchTracker />
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<QualityControl />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPage;
|
||||
1
frontend/src/pages/app/operations/production/index.ts
Normal file
1
frontend/src/pages/app/operations/production/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProductionPage } from './ProductionPage';
|
||||
412
frontend/src/pages/app/operations/recipes/RecipesPage.tsx
Normal file
412
frontend/src/pages/app/operations/recipes/RecipesPage.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Star, Clock, Users, DollarSign } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const RecipesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
category: 'bread',
|
||||
difficulty: 'medium',
|
||||
prepTime: 120,
|
||||
bakingTime: 35,
|
||||
yield: 1,
|
||||
rating: 4.8,
|
||||
cost: 2.50,
|
||||
price: 4.50,
|
||||
profit: 2.00,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['integral', 'saludable', 'artesanal'],
|
||||
description: 'Pan integral artesanal con semillas, perfecto para desayunos saludables.',
|
||||
ingredients: [
|
||||
{ name: 'Harina integral', quantity: 500, unit: 'g' },
|
||||
{ name: 'Agua', quantity: 300, unit: 'ml' },
|
||||
{ name: 'Levadura', quantity: 10, unit: 'g' },
|
||||
{ name: 'Sal', quantity: 8, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
category: 'pastry',
|
||||
difficulty: 'hard',
|
||||
prepTime: 480,
|
||||
bakingTime: 20,
|
||||
yield: 12,
|
||||
rating: 4.9,
|
||||
cost: 8.50,
|
||||
price: 18.00,
|
||||
profit: 9.50,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['francés', 'mantequilla', 'hojaldrado'],
|
||||
description: 'Croissants franceses tradicionales con laminado de mantequilla.',
|
||||
ingredients: [
|
||||
{ name: 'Harina de fuerza', quantity: 500, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 250, unit: 'g' },
|
||||
{ name: 'Leche', quantity: 150, unit: 'ml' },
|
||||
{ name: 'Azúcar', quantity: 50, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Manzana',
|
||||
category: 'cake',
|
||||
difficulty: 'easy',
|
||||
prepTime: 45,
|
||||
bakingTime: 40,
|
||||
yield: 8,
|
||||
rating: 4.6,
|
||||
cost: 4.20,
|
||||
price: 12.00,
|
||||
profit: 7.80,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['frutal', 'casera', 'temporada'],
|
||||
description: 'Tarta casera de manzana con canela y masa quebrada.',
|
||||
ingredients: [
|
||||
{ name: 'Manzanas', quantity: 1000, unit: 'g' },
|
||||
{ name: 'Harina', quantity: 250, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 125, unit: 'g' },
|
||||
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas las categorías' },
|
||||
{ value: 'bread', label: 'Panes' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tartas' },
|
||||
{ value: 'cookie', label: 'Galletas' },
|
||||
{ value: 'other', label: 'Otros' },
|
||||
];
|
||||
|
||||
const difficulties = [
|
||||
{ value: 'all', label: 'Todas las dificultades' },
|
||||
{ value: 'easy', label: 'Fácil' },
|
||||
{ value: 'medium', label: 'Medio' },
|
||||
{ value: 'hard', label: 'Difícil' },
|
||||
];
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const categoryConfig = {
|
||||
bread: { color: 'brown', text: 'Pan' },
|
||||
pastry: { color: 'yellow', text: 'Bollería' },
|
||||
cake: { color: 'pink', text: 'Tarta' },
|
||||
cookie: { color: 'orange', text: 'Galleta' },
|
||||
other: { color: 'gray', text: 'Otro' },
|
||||
};
|
||||
|
||||
const config = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const getDifficultyBadge = (difficulty: string) => {
|
||||
const difficultyConfig = {
|
||||
easy: { color: 'green', text: 'Fácil' },
|
||||
medium: { color: 'yellow', text: 'Medio' },
|
||||
hard: { color: 'red', text: 'Difícil' },
|
||||
};
|
||||
|
||||
const config = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const filteredRecipes = mockRecipes.filter(recipe => {
|
||||
const matchesSearch = recipe.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesCategory = selectedCategory === 'all' || recipe.category === selectedCategory;
|
||||
const matchesDifficulty = selectedDifficulty === 'all' || recipe.difficulty === selectedDifficulty;
|
||||
|
||||
return matchesSearch && matchesCategory && matchesDifficulty;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Recetas"
|
||||
description="Administra y organiza todas las recetas de tu panadería"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Receta
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Recetas</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{mockRecipes.length}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-[var(--color-info)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Más Populares</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">
|
||||
{mockRecipes.filter(r => r.rating > 4.7).length}
|
||||
</p>
|
||||
</div>
|
||||
<Star className="h-12 w-12 text-yellow-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Costo Promedio</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">
|
||||
€{(mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-12 w-12 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Margen Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">
|
||||
€{(mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
{difficulties.map(diff => (
|
||||
<option key={diff.value} value={diff.value}>{diff.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? 'Vista Lista' : 'Vista Cuadrícula'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipes Grid/List */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<Card key={recipe.id} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] line-clamp-1">
|
||||
{recipe.name}
|
||||
</h3>
|
||||
<div className="flex items-center ml-2">
|
||||
<Star className="h-4 w-4 text-yellow-400 fill-current" />
|
||||
<span className="text-sm text-[var(--text-secondary)] ml-1">{recipe.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--text-secondary)] text-sm mb-3 line-clamp-2">
|
||||
{recipe.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{getCategoryBadge(recipe.category)}
|
||||
{getDifficultyBadge(recipe.difficulty)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<span>{formatTime(recipe.prepTime + recipe.bakingTime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
<span>{recipe.yield} porciones</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Costo: </span>
|
||||
<span className="font-medium">€{recipe.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Precio: </span>
|
||||
<span className="font-medium text-[var(--color-success)]">€{recipe.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Ver Receta
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1">
|
||||
Producir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-[var(--bg-secondary)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Receta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Categoría
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Dificultad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Tiempo Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Rendimiento
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Costo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Precio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Margen
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<tr key={recipe.id} className="hover:bg-[var(--bg-secondary)]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
className="h-10 w-10 rounded-full mr-4"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{recipe.name}</div>
|
||||
<div className="flex items-center">
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
<span className="text-xs text-[var(--text-tertiary)] ml-1">{recipe.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getCategoryBadge(recipe.category)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getDifficultyBadge(recipe.difficulty)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{formatTime(recipe.prepTime + recipe.bakingTime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
{recipe.yield} porciones
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
||||
€{recipe.cost.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--color-success)] font-medium">
|
||||
€{recipe.price.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-purple-600 font-medium">
|
||||
€{recipe.profit.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">Ver</Button>
|
||||
<Button size="sm">Producir</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipesPage;
|
||||
412
frontend/src/pages/app/operations/recipes/RecipesPage.tsx.backup
Normal file
412
frontend/src/pages/app/operations/recipes/RecipesPage.tsx.backup
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Star, Clock, Users, DollarSign } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const RecipesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
category: 'bread',
|
||||
difficulty: 'medium',
|
||||
prepTime: 120,
|
||||
bakingTime: 35,
|
||||
yield: 1,
|
||||
rating: 4.8,
|
||||
cost: 2.50,
|
||||
price: 4.50,
|
||||
profit: 2.00,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['integral', 'saludable', 'artesanal'],
|
||||
description: 'Pan integral artesanal con semillas, perfecto para desayunos saludables.',
|
||||
ingredients: [
|
||||
{ name: 'Harina integral', quantity: 500, unit: 'g' },
|
||||
{ name: 'Agua', quantity: 300, unit: 'ml' },
|
||||
{ name: 'Levadura', quantity: 10, unit: 'g' },
|
||||
{ name: 'Sal', quantity: 8, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
category: 'pastry',
|
||||
difficulty: 'hard',
|
||||
prepTime: 480,
|
||||
bakingTime: 20,
|
||||
yield: 12,
|
||||
rating: 4.9,
|
||||
cost: 8.50,
|
||||
price: 18.00,
|
||||
profit: 9.50,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['francés', 'mantequilla', 'hojaldrado'],
|
||||
description: 'Croissants franceses tradicionales con laminado de mantequilla.',
|
||||
ingredients: [
|
||||
{ name: 'Harina de fuerza', quantity: 500, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 250, unit: 'g' },
|
||||
{ name: 'Leche', quantity: 150, unit: 'ml' },
|
||||
{ name: 'Azúcar', quantity: 50, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Manzana',
|
||||
category: 'cake',
|
||||
difficulty: 'easy',
|
||||
prepTime: 45,
|
||||
bakingTime: 40,
|
||||
yield: 8,
|
||||
rating: 4.6,
|
||||
cost: 4.20,
|
||||
price: 12.00,
|
||||
profit: 7.80,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['frutal', 'casera', 'temporada'],
|
||||
description: 'Tarta casera de manzana con canela y masa quebrada.',
|
||||
ingredients: [
|
||||
{ name: 'Manzanas', quantity: 1000, unit: 'g' },
|
||||
{ name: 'Harina', quantity: 250, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 125, unit: 'g' },
|
||||
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas las categorías' },
|
||||
{ value: 'bread', label: 'Panes' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tartas' },
|
||||
{ value: 'cookie', label: 'Galletas' },
|
||||
{ value: 'other', label: 'Otros' },
|
||||
];
|
||||
|
||||
const difficulties = [
|
||||
{ value: 'all', label: 'Todas las dificultades' },
|
||||
{ value: 'easy', label: 'Fácil' },
|
||||
{ value: 'medium', label: 'Medio' },
|
||||
{ value: 'hard', label: 'Difícil' },
|
||||
];
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const categoryConfig = {
|
||||
bread: { color: 'brown', text: 'Pan' },
|
||||
pastry: { color: 'yellow', text: 'Bollería' },
|
||||
cake: { color: 'pink', text: 'Tarta' },
|
||||
cookie: { color: 'orange', text: 'Galleta' },
|
||||
other: { color: 'gray', text: 'Otro' },
|
||||
};
|
||||
|
||||
const config = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const getDifficultyBadge = (difficulty: string) => {
|
||||
const difficultyConfig = {
|
||||
easy: { color: 'green', text: 'Fácil' },
|
||||
medium: { color: 'yellow', text: 'Medio' },
|
||||
hard: { color: 'red', text: 'Difícil' },
|
||||
};
|
||||
|
||||
const config = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const filteredRecipes = mockRecipes.filter(recipe => {
|
||||
const matchesSearch = recipe.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesCategory = selectedCategory === 'all' || recipe.category === selectedCategory;
|
||||
const matchesDifficulty = selectedDifficulty === 'all' || recipe.difficulty === selectedDifficulty;
|
||||
|
||||
return matchesSearch && matchesCategory && matchesDifficulty;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Recetas"
|
||||
description="Administra y organiza todas las recetas de tu panadería"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Receta
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Recetas</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{mockRecipes.length}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Más Populares</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">
|
||||
{mockRecipes.filter(r => r.rating > 4.7).length}
|
||||
</p>
|
||||
</div>
|
||||
<Star className="h-12 w-12 text-yellow-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Costo Promedio</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
€{(mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Margen Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">
|
||||
€{(mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{difficulties.map(diff => (
|
||||
<option key={diff.value} value={diff.value}>{diff.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? 'Vista Lista' : 'Vista Cuadrícula'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipes Grid/List */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<Card key={recipe.id} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
|
||||
{recipe.name}
|
||||
</h3>
|
||||
<div className="flex items-center ml-2">
|
||||
<Star className="h-4 w-4 text-yellow-400 fill-current" />
|
||||
<span className="text-sm text-gray-600 ml-1">{recipe.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
{recipe.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{getCategoryBadge(recipe.category)}
|
||||
{getDifficultyBadge(recipe.difficulty)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 text-sm text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<span>{formatTime(recipe.prepTime + recipe.bakingTime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
<span>{recipe.yield} porciones</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Costo: </span>
|
||||
<span className="font-medium">€{recipe.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Precio: </span>
|
||||
<span className="font-medium text-green-600">€{recipe.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Ver Receta
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1">
|
||||
Producir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Receta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Categoría
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Dificultad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tiempo Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rendimiento
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Costo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Precio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Margen
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<tr key={recipe.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
className="h-10 w-10 rounded-full mr-4"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{recipe.name}</div>
|
||||
<div className="flex items-center">
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
<span className="text-xs text-gray-500 ml-1">{recipe.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getCategoryBadge(recipe.category)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getDifficultyBadge(recipe.difficulty)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatTime(recipe.prepTime + recipe.bakingTime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recipe.yield} porciones
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
€{recipe.cost.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 font-medium">
|
||||
€{recipe.price.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-purple-600 font-medium">
|
||||
€{recipe.profit.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">Ver</Button>
|
||||
<Button size="sm">Producir</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipesPage;
|
||||
1
frontend/src/pages/app/operations/recipes/index.ts
Normal file
1
frontend/src/pages/app/operations/recipes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as RecipesPage } from './RecipesPage';
|
||||
@@ -0,0 +1,481 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const BakeryConfigPage: React.FC = () => {
|
||||
const [config, setConfig] = useState({
|
||||
general: {
|
||||
name: 'Panadería Artesanal San Miguel',
|
||||
description: 'Panadería tradicional con más de 30 años de experiencia',
|
||||
logo: '',
|
||||
website: 'https://panaderiasanmiguel.com',
|
||||
email: 'info@panaderiasanmiguel.com',
|
||||
phone: '+34 912 345 678'
|
||||
},
|
||||
location: {
|
||||
address: 'Calle Mayor 123',
|
||||
city: 'Madrid',
|
||||
postalCode: '28001',
|
||||
country: 'España',
|
||||
coordinates: {
|
||||
lat: 40.4168,
|
||||
lng: -3.7038
|
||||
}
|
||||
},
|
||||
schedule: {
|
||||
monday: { open: '07:00', close: '20:00', closed: false },
|
||||
tuesday: { open: '07:00', close: '20:00', closed: false },
|
||||
wednesday: { open: '07:00', close: '20:00', closed: false },
|
||||
thursday: { open: '07:00', close: '20:00', closed: false },
|
||||
friday: { open: '07:00', close: '20:00', closed: false },
|
||||
saturday: { open: '08:00', close: '14:00', closed: false },
|
||||
sunday: { open: '09:00', close: '13:00', closed: false }
|
||||
},
|
||||
business: {
|
||||
taxId: 'B12345678',
|
||||
registrationNumber: 'REG-2024-001',
|
||||
licenseNumber: 'LIC-FOOD-2024',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
},
|
||||
preferences: {
|
||||
enableOnlineOrders: true,
|
||||
enableReservations: false,
|
||||
enableDelivery: true,
|
||||
deliveryRadius: 5,
|
||||
minimumOrderAmount: 15.00,
|
||||
enableLoyaltyProgram: true,
|
||||
autoBackup: true,
|
||||
emailNotifications: true,
|
||||
smsNotifications: false
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General', icon: Store },
|
||||
{ id: 'location', label: 'Ubicación', icon: MapPin },
|
||||
{ id: 'schedule', label: 'Horarios', icon: Clock },
|
||||
{ id: 'business', label: 'Empresa', icon: Globe }
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ key: 'monday', label: 'Lunes' },
|
||||
{ key: 'tuesday', label: 'Martes' },
|
||||
{ key: 'wednesday', label: 'Miércoles' },
|
||||
{ key: 'thursday', label: 'Jueves' },
|
||||
{ key: 'friday', label: 'Viernes' },
|
||||
{ key: 'saturday', label: 'Sábado' },
|
||||
{ key: 'sunday', label: 'Domingo' }
|
||||
];
|
||||
|
||||
const handleInputChange = (section: string, field: string, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleScheduleChange = (day: string, field: string, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
schedule: {
|
||||
...prev.schedule,
|
||||
[day]: {
|
||||
...prev.schedule[day as keyof typeof prev.schedule],
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving bakery config:', config);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Configuración de Panadería"
|
||||
description="Configura los datos básicos y preferencias de tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Card className="p-4">
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center space-x-3 px-3 py-2 text-left rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--color-info)]/10 text-[var(--color-info)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
{activeTab === 'general' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">Información General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Nombre de la Panadería
|
||||
</label>
|
||||
<Input
|
||||
value={config.general.name}
|
||||
onChange={(e) => handleInputChange('general', 'name', e.target.value)}
|
||||
placeholder="Nombre de tu panadería"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Sitio Web
|
||||
</label>
|
||||
<Input
|
||||
value={config.general.website}
|
||||
onChange={(e) => handleInputChange('general', 'website', e.target.value)}
|
||||
placeholder="https://tu-panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={config.general.description}
|
||||
onChange={(e) => handleInputChange('general', 'description', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="Describe tu panadería..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Email de Contacto
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
value={config.general.email}
|
||||
onChange={(e) => handleInputChange('general', 'email', e.target.value)}
|
||||
className="pl-10"
|
||||
type="email"
|
||||
placeholder="contacto@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
value={config.general.phone}
|
||||
onChange={(e) => handleInputChange('general', 'phone', e.target.value)}
|
||||
className="pl-10"
|
||||
type="tel"
|
||||
placeholder="+34 912 345 678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'location' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">Ubicación</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Dirección
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.address}
|
||||
onChange={(e) => handleInputChange('location', 'address', e.target.value)}
|
||||
placeholder="Calle, número, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Ciudad
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.city}
|
||||
onChange={(e) => handleInputChange('location', 'city', e.target.value)}
|
||||
placeholder="Ciudad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Código Postal
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.postalCode}
|
||||
onChange={(e) => handleInputChange('location', 'postalCode', e.target.value)}
|
||||
placeholder="28001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
País
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.country}
|
||||
onChange={(e) => handleInputChange('location', 'country', e.target.value)}
|
||||
placeholder="España"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Latitud
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.coordinates.lat}
|
||||
onChange={(e) => handleInputChange('location', 'coordinates', {
|
||||
...config.location.coordinates,
|
||||
lat: parseFloat(e.target.value) || 0
|
||||
})}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="40.4168"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Longitud
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.coordinates.lng}
|
||||
onChange={(e) => handleInputChange('location', 'coordinates', {
|
||||
...config.location.coordinates,
|
||||
lng: parseFloat(e.target.value) || 0
|
||||
})}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="-3.7038"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">Horarios de Apertura</h3>
|
||||
<div className="space-y-4">
|
||||
{daysOfWeek.map((day) => {
|
||||
const schedule = config.schedule[day.key as keyof typeof config.schedule];
|
||||
return (
|
||||
<div key={day.key} className="flex items-center space-x-4 p-4 border rounded-lg">
|
||||
<div className="w-20">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">{day.label}</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={schedule.closed}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'closed', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--text-secondary)]">Cerrado</span>
|
||||
</label>
|
||||
|
||||
{!schedule.closed && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Apertura</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.open}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'open', e.target.value)}
|
||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Cierre</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.close}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'close', e.target.value)}
|
||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'business' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">Datos de Empresa</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
NIF/CIF
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.taxId}
|
||||
onChange={(e) => handleInputChange('business', 'taxId', e.target.value)}
|
||||
placeholder="B12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Número de Registro
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.registrationNumber}
|
||||
onChange={(e) => handleInputChange('business', 'registrationNumber', e.target.value)}
|
||||
placeholder="REG-2024-001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Licencia Sanitaria
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.licenseNumber}
|
||||
onChange={(e) => handleInputChange('business', 'licenseNumber', e.target.value)}
|
||||
placeholder="LIC-FOOD-2024"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
value={config.business.currency}
|
||||
onChange={(e) => handleInputChange('business', 'currency', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Zona Horaria
|
||||
</label>
|
||||
<select
|
||||
value={config.business.timezone}
|
||||
onChange={(e) => handleInputChange('business', 'timezone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="Europe/Madrid">Madrid (GMT+1)</option>
|
||||
<option value="Europe/London">Londres (GMT)</option>
|
||||
<option value="America/New_York">Nueva York (GMT-5)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={config.business.language}
|
||||
onChange={(e) => handleInputChange('business', 'language', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-[var(--color-info)] bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakeryConfigPage;
|
||||
@@ -0,0 +1,481 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const BakeryConfigPage: React.FC = () => {
|
||||
const [config, setConfig] = useState({
|
||||
general: {
|
||||
name: 'Panadería Artesanal San Miguel',
|
||||
description: 'Panadería tradicional con más de 30 años de experiencia',
|
||||
logo: '',
|
||||
website: 'https://panaderiasanmiguel.com',
|
||||
email: 'info@panaderiasanmiguel.com',
|
||||
phone: '+34 912 345 678'
|
||||
},
|
||||
location: {
|
||||
address: 'Calle Mayor 123',
|
||||
city: 'Madrid',
|
||||
postalCode: '28001',
|
||||
country: 'España',
|
||||
coordinates: {
|
||||
lat: 40.4168,
|
||||
lng: -3.7038
|
||||
}
|
||||
},
|
||||
schedule: {
|
||||
monday: { open: '07:00', close: '20:00', closed: false },
|
||||
tuesday: { open: '07:00', close: '20:00', closed: false },
|
||||
wednesday: { open: '07:00', close: '20:00', closed: false },
|
||||
thursday: { open: '07:00', close: '20:00', closed: false },
|
||||
friday: { open: '07:00', close: '20:00', closed: false },
|
||||
saturday: { open: '08:00', close: '14:00', closed: false },
|
||||
sunday: { open: '09:00', close: '13:00', closed: false }
|
||||
},
|
||||
business: {
|
||||
taxId: 'B12345678',
|
||||
registrationNumber: 'REG-2024-001',
|
||||
licenseNumber: 'LIC-FOOD-2024',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
},
|
||||
preferences: {
|
||||
enableOnlineOrders: true,
|
||||
enableReservations: false,
|
||||
enableDelivery: true,
|
||||
deliveryRadius: 5,
|
||||
minimumOrderAmount: 15.00,
|
||||
enableLoyaltyProgram: true,
|
||||
autoBackup: true,
|
||||
emailNotifications: true,
|
||||
smsNotifications: false
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General', icon: Store },
|
||||
{ id: 'location', label: 'Ubicación', icon: MapPin },
|
||||
{ id: 'schedule', label: 'Horarios', icon: Clock },
|
||||
{ id: 'business', label: 'Empresa', icon: Globe }
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ key: 'monday', label: 'Lunes' },
|
||||
{ key: 'tuesday', label: 'Martes' },
|
||||
{ key: 'wednesday', label: 'Miércoles' },
|
||||
{ key: 'thursday', label: 'Jueves' },
|
||||
{ key: 'friday', label: 'Viernes' },
|
||||
{ key: 'saturday', label: 'Sábado' },
|
||||
{ key: 'sunday', label: 'Domingo' }
|
||||
];
|
||||
|
||||
const handleInputChange = (section: string, field: string, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleScheduleChange = (day: string, field: string, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
schedule: {
|
||||
...prev.schedule,
|
||||
[day]: {
|
||||
...prev.schedule[day as keyof typeof prev.schedule],
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving bakery config:', config);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Configuración de Panadería"
|
||||
description="Configura los datos básicos y preferencias de tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Card className="p-4">
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center space-x-3 px-3 py-2 text-left rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
{activeTab === 'general' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre de la Panadería
|
||||
</label>
|
||||
<Input
|
||||
value={config.general.name}
|
||||
onChange={(e) => handleInputChange('general', 'name', e.target.value)}
|
||||
placeholder="Nombre de tu panadería"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sitio Web
|
||||
</label>
|
||||
<Input
|
||||
value={config.general.website}
|
||||
onChange={(e) => handleInputChange('general', 'website', e.target.value)}
|
||||
placeholder="https://tu-panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={config.general.description}
|
||||
onChange={(e) => handleInputChange('general', 'description', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Describe tu panadería..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email de Contacto
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
value={config.general.email}
|
||||
onChange={(e) => handleInputChange('general', 'email', e.target.value)}
|
||||
className="pl-10"
|
||||
type="email"
|
||||
placeholder="contacto@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
value={config.general.phone}
|
||||
onChange={(e) => handleInputChange('general', 'phone', e.target.value)}
|
||||
className="pl-10"
|
||||
type="tel"
|
||||
placeholder="+34 912 345 678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'location' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Ubicación</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dirección
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.address}
|
||||
onChange={(e) => handleInputChange('location', 'address', e.target.value)}
|
||||
placeholder="Calle, número, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ciudad
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.city}
|
||||
onChange={(e) => handleInputChange('location', 'city', e.target.value)}
|
||||
placeholder="Ciudad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Código Postal
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.postalCode}
|
||||
onChange={(e) => handleInputChange('location', 'postalCode', e.target.value)}
|
||||
placeholder="28001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
País
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.country}
|
||||
onChange={(e) => handleInputChange('location', 'country', e.target.value)}
|
||||
placeholder="España"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Latitud
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.coordinates.lat}
|
||||
onChange={(e) => handleInputChange('location', 'coordinates', {
|
||||
...config.location.coordinates,
|
||||
lat: parseFloat(e.target.value) || 0
|
||||
})}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="40.4168"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Longitud
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.coordinates.lng}
|
||||
onChange={(e) => handleInputChange('location', 'coordinates', {
|
||||
...config.location.coordinates,
|
||||
lng: parseFloat(e.target.value) || 0
|
||||
})}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="-3.7038"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Horarios de Apertura</h3>
|
||||
<div className="space-y-4">
|
||||
{daysOfWeek.map((day) => {
|
||||
const schedule = config.schedule[day.key as keyof typeof config.schedule];
|
||||
return (
|
||||
<div key={day.key} className="flex items-center space-x-4 p-4 border rounded-lg">
|
||||
<div className="w-20">
|
||||
<span className="text-sm font-medium text-gray-700">{day.label}</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={schedule.closed}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'closed', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">Cerrado</span>
|
||||
</label>
|
||||
|
||||
{!schedule.closed && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Apertura</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.open}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'open', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Cierre</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.close}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'close', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'business' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Datos de Empresa</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
NIF/CIF
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.taxId}
|
||||
onChange={(e) => handleInputChange('business', 'taxId', e.target.value)}
|
||||
placeholder="B12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Número de Registro
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.registrationNumber}
|
||||
onChange={(e) => handleInputChange('business', 'registrationNumber', e.target.value)}
|
||||
placeholder="REG-2024-001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Licencia Sanitaria
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.licenseNumber}
|
||||
onChange={(e) => handleInputChange('business', 'licenseNumber', e.target.value)}
|
||||
placeholder="LIC-FOOD-2024"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
value={config.business.currency}
|
||||
onChange={(e) => handleInputChange('business', 'currency', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Zona Horaria
|
||||
</label>
|
||||
<select
|
||||
value={config.business.timezone}
|
||||
onChange={(e) => handleInputChange('business', 'timezone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="Europe/Madrid">Madrid (GMT+1)</option>
|
||||
<option value="Europe/London">Londres (GMT)</option>
|
||||
<option value="America/New_York">Nueva York (GMT-5)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={config.business.language}
|
||||
onChange={(e) => handleInputChange('business', 'language', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-blue-600 bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakeryConfigPage;
|
||||
1
frontend/src/pages/app/settings/bakery-config/index.ts
Normal file
1
frontend/src/pages/app/settings/bakery-config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BakeryConfigPage } from './BakeryConfigPage';
|
||||
591
frontend/src/pages/app/settings/system/SystemSettingsPage.tsx
Normal file
591
frontend/src/pages/app/settings/system/SystemSettingsPage.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Shield, Database, Bell, Wifi, HardDrive, Activity, Save, RotateCcw, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const SystemSettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
general: {
|
||||
systemName: 'Bakery-IA Sistema',
|
||||
version: '2.1.0',
|
||||
environment: 'production',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es',
|
||||
currency: 'EUR',
|
||||
dateFormat: 'dd/mm/yyyy',
|
||||
autoUpdates: true,
|
||||
maintenanceMode: false
|
||||
},
|
||||
security: {
|
||||
sessionTimeout: 120,
|
||||
maxLoginAttempts: 5,
|
||||
passwordComplexity: true,
|
||||
twoFactorAuth: false,
|
||||
ipWhitelist: '',
|
||||
sslEnabled: true,
|
||||
encryptionLevel: 'AES256',
|
||||
auditLogging: true,
|
||||
dataRetention: 365
|
||||
},
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
name: 'bakery_ia_db',
|
||||
backupFrequency: 'daily',
|
||||
backupRetention: 30,
|
||||
maintenanceWindow: '02:00-04:00',
|
||||
connectionPool: 20,
|
||||
slowQueryLogging: true,
|
||||
performanceMonitoring: true
|
||||
},
|
||||
notifications: {
|
||||
emailEnabled: true,
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
slackIntegration: false,
|
||||
webhookUrl: '',
|
||||
alertThreshold: 'medium',
|
||||
systemAlerts: true,
|
||||
performanceAlerts: true,
|
||||
securityAlerts: true
|
||||
},
|
||||
performance: {
|
||||
cacheEnabled: true,
|
||||
cacheTtl: 3600,
|
||||
compressionEnabled: true,
|
||||
cdnEnabled: false,
|
||||
loadBalancing: false,
|
||||
memoryLimit: '2GB',
|
||||
cpuThreshold: 80,
|
||||
diskSpaceThreshold: 85,
|
||||
logLevel: 'info'
|
||||
}
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General', icon: Settings },
|
||||
{ id: 'security', label: 'Seguridad', icon: Shield },
|
||||
{ id: 'database', label: 'Base de Datos', icon: Database },
|
||||
{ id: 'notifications', label: 'Notificaciones', icon: Bell },
|
||||
{ id: 'performance', label: 'Rendimiento', icon: Activity }
|
||||
];
|
||||
|
||||
const systemStats = {
|
||||
uptime: '15 días, 7 horas',
|
||||
memoryUsage: 68,
|
||||
diskUsage: 42,
|
||||
cpuUsage: 23,
|
||||
activeUsers: 12,
|
||||
lastBackup: '2024-01-26 02:15:00',
|
||||
version: '2.1.0',
|
||||
environment: 'Production'
|
||||
};
|
||||
|
||||
const systemLogs = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
level: 'INFO',
|
||||
category: 'System',
|
||||
message: 'Backup automático completado exitosamente',
|
||||
details: 'Database: bakery_ia_db, Size: 245MB, Duration: 3.2s'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
level: 'WARN',
|
||||
category: 'Performance',
|
||||
message: 'Uso de CPU alto detectado',
|
||||
details: 'CPU usage: 89% for 5 minutes, Process: data-processor'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
level: 'INFO',
|
||||
category: 'Security',
|
||||
message: 'Usuario admin autenticado correctamente',
|
||||
details: 'IP: 192.168.1.100, Session: sess_abc123'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
level: 'ERROR',
|
||||
category: 'Database',
|
||||
message: 'Consulta lenta detectada',
|
||||
details: 'Query duration: 5.8s, Table: sales_analytics'
|
||||
}
|
||||
];
|
||||
|
||||
const handleSettingChange = (section: string, field: string, value: any) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Saving system settings:', settings);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'ERROR': return 'red';
|
||||
case 'WARN': return 'yellow';
|
||||
case 'INFO': return 'blue';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getUsageColor = (usage: number) => {
|
||||
if (usage >= 80) return 'text-[var(--color-error)]';
|
||||
if (usage >= 60) return 'text-yellow-600';
|
||||
return 'text-[var(--color-success)]';
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Nombre del Sistema
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.general.systemName}
|
||||
onChange={(e) => handleSettingChange('general', 'systemName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Zona Horaria
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.timezone}
|
||||
onChange={(e) => handleSettingChange('general', 'timezone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="Europe/Madrid">Madrid (GMT+1)</option>
|
||||
<option value="Europe/London">Londres (GMT)</option>
|
||||
<option value="America/New_York">Nueva York (GMT-5)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Idioma del Sistema
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.language}
|
||||
onChange={(e) => handleSettingChange('general', 'language', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Formato de Fecha
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.dateFormat}
|
||||
onChange={(e) => handleSettingChange('general', 'dateFormat', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<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 className="space-y-4">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.general.autoUpdates}
|
||||
onChange={(e) => handleSettingChange('general', 'autoUpdates', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Actualizaciones Automáticas</span>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Instalar actualizaciones de seguridad automáticamente</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.general.maintenanceMode}
|
||||
onChange={(e) => handleSettingChange('general', 'maintenanceMode', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Modo Mantenimiento</span>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Deshabilitar acceso durante mantenimiento</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'security':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Tiempo de Sesión (minutos)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.sessionTimeout}
|
||||
onChange={(e) => handleSettingChange('security', 'sessionTimeout', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Intentos Máximos de Login
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.maxLoginAttempts}
|
||||
onChange={(e) => handleSettingChange('security', 'maxLoginAttempts', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Nivel de Encriptación
|
||||
</label>
|
||||
<select
|
||||
value={settings.security.encryptionLevel}
|
||||
onChange={(e) => handleSettingChange('security', 'encryptionLevel', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="AES128">AES-128</option>
|
||||
<option value="AES256">AES-256</option>
|
||||
<option value="AES512">AES-512</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Retención de Datos (días)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.dataRetention}
|
||||
onChange={(e) => handleSettingChange('security', 'dataRetention', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security.passwordComplexity}
|
||||
onChange={(e) => handleSettingChange('security', 'passwordComplexity', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Complejidad de Contraseñas</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security.twoFactorAuth}
|
||||
onChange={(e) => handleSettingChange('security', 'twoFactorAuth', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Autenticación de Dos Factores</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security.auditLogging}
|
||||
onChange={(e) => handleSettingChange('security', 'auditLogging', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Registro de Auditoría</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'database':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800">Configuración Avanzada</p>
|
||||
<p className="text-sm text-yellow-700">Cambios incorrectos pueden afectar el rendimiento del sistema</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Frecuencia de Backup
|
||||
</label>
|
||||
<select
|
||||
value={settings.database.backupFrequency}
|
||||
onChange={(e) => handleSettingChange('database', 'backupFrequency', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="hourly">Cada Hora</option>
|
||||
<option value="daily">Diario</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Retención de Backups (días)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.database.backupRetention}
|
||||
onChange={(e) => handleSettingChange('database', 'backupRetention', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Ventana de Mantenimiento
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.database.maintenanceWindow}
|
||||
onChange={(e) => handleSettingChange('database', 'maintenanceWindow', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="02:00-04:00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Pool de Conexiones
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.database.connectionPool}
|
||||
onChange={(e) => handleSettingChange('database', 'connectionPool', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.database.slowQueryLogging}
|
||||
onChange={(e) => handleSettingChange('database', 'slowQueryLogging', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Registro de Consultas Lentas</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.database.performanceMonitoring}
|
||||
onChange={(e) => handleSettingChange('database', 'performanceMonitoring', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Monitoreo de Rendimiento</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>Contenido no disponible</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Configuración del Sistema"
|
||||
description="Administra la configuración técnica y seguridad del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Tiempo Activo</p>
|
||||
<p className="text-lg font-bold text-[var(--color-success)]">{systemStats.uptime}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Uso de Memoria</p>
|
||||
<p className={`text-lg font-bold ${getUsageColor(systemStats.memoryUsage)}`}>
|
||||
{systemStats.memoryUsage}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<HardDrive className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Uso de CPU</p>
|
||||
<p className={`text-lg font-bold ${getUsageColor(systemStats.cpuUsage)}`}>
|
||||
{systemStats.cpuUsage}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Usuarios Activos</p>
|
||||
<p className="text-lg font-bold text-[var(--color-primary)]">{systemStats.activeUsers}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Wifi className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Settings Tabs */}
|
||||
<div>
|
||||
<Card className="p-4">
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center space-x-3 px-3 py-2 text-left rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--color-info)]/10 text-[var(--color-info)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6">
|
||||
{tabs.find(tab => tab.id === activeTab)?.label}
|
||||
</h3>
|
||||
{renderTabContent()}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Logs */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Registro del Sistema</h3>
|
||||
<div className="space-y-3">
|
||||
{systemLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start space-x-4 p-3 border rounded-lg">
|
||||
<Badge variant={getLevelColor(log.level)} className="mt-1">
|
||||
{log.level}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{log.message}</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">{log.category}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{log.details}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">{log.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-[var(--color-info)] bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSettingsPage;
|
||||
@@ -0,0 +1,591 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Shield, Database, Bell, Wifi, HardDrive, Activity, Save, RotateCcw, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const SystemSettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
general: {
|
||||
systemName: 'Bakery-IA Sistema',
|
||||
version: '2.1.0',
|
||||
environment: 'production',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es',
|
||||
currency: 'EUR',
|
||||
dateFormat: 'dd/mm/yyyy',
|
||||
autoUpdates: true,
|
||||
maintenanceMode: false
|
||||
},
|
||||
security: {
|
||||
sessionTimeout: 120,
|
||||
maxLoginAttempts: 5,
|
||||
passwordComplexity: true,
|
||||
twoFactorAuth: false,
|
||||
ipWhitelist: '',
|
||||
sslEnabled: true,
|
||||
encryptionLevel: 'AES256',
|
||||
auditLogging: true,
|
||||
dataRetention: 365
|
||||
},
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
name: 'bakery_ia_db',
|
||||
backupFrequency: 'daily',
|
||||
backupRetention: 30,
|
||||
maintenanceWindow: '02:00-04:00',
|
||||
connectionPool: 20,
|
||||
slowQueryLogging: true,
|
||||
performanceMonitoring: true
|
||||
},
|
||||
notifications: {
|
||||
emailEnabled: true,
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
slackIntegration: false,
|
||||
webhookUrl: '',
|
||||
alertThreshold: 'medium',
|
||||
systemAlerts: true,
|
||||
performanceAlerts: true,
|
||||
securityAlerts: true
|
||||
},
|
||||
performance: {
|
||||
cacheEnabled: true,
|
||||
cacheTtl: 3600,
|
||||
compressionEnabled: true,
|
||||
cdnEnabled: false,
|
||||
loadBalancing: false,
|
||||
memoryLimit: '2GB',
|
||||
cpuThreshold: 80,
|
||||
diskSpaceThreshold: 85,
|
||||
logLevel: 'info'
|
||||
}
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General', icon: Settings },
|
||||
{ id: 'security', label: 'Seguridad', icon: Shield },
|
||||
{ id: 'database', label: 'Base de Datos', icon: Database },
|
||||
{ id: 'notifications', label: 'Notificaciones', icon: Bell },
|
||||
{ id: 'performance', label: 'Rendimiento', icon: Activity }
|
||||
];
|
||||
|
||||
const systemStats = {
|
||||
uptime: '15 días, 7 horas',
|
||||
memoryUsage: 68,
|
||||
diskUsage: 42,
|
||||
cpuUsage: 23,
|
||||
activeUsers: 12,
|
||||
lastBackup: '2024-01-26 02:15:00',
|
||||
version: '2.1.0',
|
||||
environment: 'Production'
|
||||
};
|
||||
|
||||
const systemLogs = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
level: 'INFO',
|
||||
category: 'System',
|
||||
message: 'Backup automático completado exitosamente',
|
||||
details: 'Database: bakery_ia_db, Size: 245MB, Duration: 3.2s'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
level: 'WARN',
|
||||
category: 'Performance',
|
||||
message: 'Uso de CPU alto detectado',
|
||||
details: 'CPU usage: 89% for 5 minutes, Process: data-processor'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
level: 'INFO',
|
||||
category: 'Security',
|
||||
message: 'Usuario admin autenticado correctamente',
|
||||
details: 'IP: 192.168.1.100, Session: sess_abc123'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
level: 'ERROR',
|
||||
category: 'Database',
|
||||
message: 'Consulta lenta detectada',
|
||||
details: 'Query duration: 5.8s, Table: sales_analytics'
|
||||
}
|
||||
];
|
||||
|
||||
const handleSettingChange = (section: string, field: string, value: any) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Saving system settings:', settings);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'ERROR': return 'red';
|
||||
case 'WARN': return 'yellow';
|
||||
case 'INFO': return 'blue';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getUsageColor = (usage: number) => {
|
||||
if (usage >= 80) return 'text-red-600';
|
||||
if (usage >= 60) return 'text-yellow-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre del Sistema
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.general.systemName}
|
||||
onChange={(e) => handleSettingChange('general', 'systemName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Zona Horaria
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.timezone}
|
||||
onChange={(e) => handleSettingChange('general', 'timezone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="Europe/Madrid">Madrid (GMT+1)</option>
|
||||
<option value="Europe/London">Londres (GMT)</option>
|
||||
<option value="America/New_York">Nueva York (GMT-5)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Idioma del Sistema
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.language}
|
||||
onChange={(e) => handleSettingChange('general', 'language', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Formato de Fecha
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.dateFormat}
|
||||
onChange={(e) => handleSettingChange('general', 'dateFormat', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<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 className="space-y-4">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.general.autoUpdates}
|
||||
onChange={(e) => handleSettingChange('general', 'autoUpdates', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Actualizaciones Automáticas</span>
|
||||
<p className="text-xs text-gray-500">Instalar actualizaciones de seguridad automáticamente</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.general.maintenanceMode}
|
||||
onChange={(e) => handleSettingChange('general', 'maintenanceMode', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Modo Mantenimiento</span>
|
||||
<p className="text-xs text-gray-500">Deshabilitar acceso durante mantenimiento</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'security':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tiempo de Sesión (minutos)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.sessionTimeout}
|
||||
onChange={(e) => handleSettingChange('security', 'sessionTimeout', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Intentos Máximos de Login
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.maxLoginAttempts}
|
||||
onChange={(e) => handleSettingChange('security', 'maxLoginAttempts', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nivel de Encriptación
|
||||
</label>
|
||||
<select
|
||||
value={settings.security.encryptionLevel}
|
||||
onChange={(e) => handleSettingChange('security', 'encryptionLevel', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="AES128">AES-128</option>
|
||||
<option value="AES256">AES-256</option>
|
||||
<option value="AES512">AES-512</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Retención de Datos (días)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.dataRetention}
|
||||
onChange={(e) => handleSettingChange('security', 'dataRetention', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security.passwordComplexity}
|
||||
onChange={(e) => handleSettingChange('security', 'passwordComplexity', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Complejidad de Contraseñas</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security.twoFactorAuth}
|
||||
onChange={(e) => handleSettingChange('security', 'twoFactorAuth', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Autenticación de Dos Factores</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.security.auditLogging}
|
||||
onChange={(e) => handleSettingChange('security', 'auditLogging', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Registro de Auditoría</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'database':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800">Configuración Avanzada</p>
|
||||
<p className="text-sm text-yellow-700">Cambios incorrectos pueden afectar el rendimiento del sistema</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Frecuencia de Backup
|
||||
</label>
|
||||
<select
|
||||
value={settings.database.backupFrequency}
|
||||
onChange={(e) => handleSettingChange('database', 'backupFrequency', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="hourly">Cada Hora</option>
|
||||
<option value="daily">Diario</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Retención de Backups (días)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.database.backupRetention}
|
||||
onChange={(e) => handleSettingChange('database', 'backupRetention', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ventana de Mantenimiento
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.database.maintenanceWindow}
|
||||
onChange={(e) => handleSettingChange('database', 'maintenanceWindow', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="02:00-04:00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Pool de Conexiones
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.database.connectionPool}
|
||||
onChange={(e) => handleSettingChange('database', 'connectionPool', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.database.slowQueryLogging}
|
||||
onChange={(e) => handleSettingChange('database', 'slowQueryLogging', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Registro de Consultas Lentas</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.database.performanceMonitoring}
|
||||
onChange={(e) => handleSettingChange('database', 'performanceMonitoring', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Monitoreo de Rendimiento</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>Contenido no disponible</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Configuración del Sistema"
|
||||
description="Administra la configuración técnica y seguridad del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Tiempo Activo</p>
|
||||
<p className="text-lg font-bold text-green-600">{systemStats.uptime}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Uso de Memoria</p>
|
||||
<p className={`text-lg font-bold ${getUsageColor(systemStats.memoryUsage)}`}>
|
||||
{systemStats.memoryUsage}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<HardDrive className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Uso de CPU</p>
|
||||
<p className={`text-lg font-bold ${getUsageColor(systemStats.cpuUsage)}`}>
|
||||
{systemStats.cpuUsage}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Usuarios Activos</p>
|
||||
<p className="text-lg font-bold text-orange-600">{systemStats.activeUsers}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Wifi className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Settings Tabs */}
|
||||
<div>
|
||||
<Card className="p-4">
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center space-x-3 px-3 py-2 text-left rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">
|
||||
{tabs.find(tab => tab.id === activeTab)?.label}
|
||||
</h3>
|
||||
{renderTabContent()}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Logs */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Registro del Sistema</h3>
|
||||
<div className="space-y-3">
|
||||
{systemLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start space-x-4 p-3 border rounded-lg">
|
||||
<Badge variant={getLevelColor(log.level)} className="mt-1">
|
||||
{log.level}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">{log.message}</span>
|
||||
<span className="text-xs text-gray-500">{log.category}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{log.details}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{log.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-blue-600 bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSettingsPage;
|
||||
1
frontend/src/pages/app/settings/system/index.ts
Normal file
1
frontend/src/pages/app/settings/system/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SystemSettingsPage } from './SystemSettingsPage';
|
||||
406
frontend/src/pages/app/settings/team/TeamPage.tsx
Normal file
406
frontend/src/pages/app/settings/team/TeamPage.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TeamPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const teamMembers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'María González',
|
||||
email: 'maria.gonzalez@panaderia.com',
|
||||
phone: '+34 600 123 456',
|
||||
role: 'manager',
|
||||
department: 'Administración',
|
||||
status: 'active',
|
||||
joinDate: '2022-03-15',
|
||||
lastLogin: '2024-01-26 09:30:00',
|
||||
permissions: ['inventory', 'sales', 'reports', 'team'],
|
||||
avatar: '/avatars/maria.jpg',
|
||||
schedule: {
|
||||
monday: '07:00-15:00',
|
||||
tuesday: '07:00-15:00',
|
||||
wednesday: '07:00-15:00',
|
||||
thursday: '07:00-15:00',
|
||||
friday: '07:00-15:00',
|
||||
saturday: 'Libre',
|
||||
sunday: 'Libre'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Carlos Rodríguez',
|
||||
email: 'carlos.rodriguez@panaderia.com',
|
||||
phone: '+34 600 234 567',
|
||||
role: 'baker',
|
||||
department: 'Producción',
|
||||
status: 'active',
|
||||
joinDate: '2021-09-20',
|
||||
lastLogin: '2024-01-26 08:45:00',
|
||||
permissions: ['production', 'inventory'],
|
||||
avatar: '/avatars/carlos.jpg',
|
||||
schedule: {
|
||||
monday: '05:00-13:00',
|
||||
tuesday: '05:00-13:00',
|
||||
wednesday: '05:00-13:00',
|
||||
thursday: '05:00-13:00',
|
||||
friday: '05:00-13:00',
|
||||
saturday: '05:00-11:00',
|
||||
sunday: 'Libre'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Ana Martínez',
|
||||
email: 'ana.martinez@panaderia.com',
|
||||
phone: '+34 600 345 678',
|
||||
role: 'cashier',
|
||||
department: 'Ventas',
|
||||
status: 'active',
|
||||
joinDate: '2023-01-10',
|
||||
lastLogin: '2024-01-26 10:15:00',
|
||||
permissions: ['sales', 'pos'],
|
||||
avatar: '/avatars/ana.jpg',
|
||||
schedule: {
|
||||
monday: '08:00-16:00',
|
||||
tuesday: '08:00-16:00',
|
||||
wednesday: 'Libre',
|
||||
thursday: '08:00-16:00',
|
||||
friday: '08:00-16:00',
|
||||
saturday: '09:00-14:00',
|
||||
sunday: '09:00-14:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Luis Fernández',
|
||||
email: 'luis.fernandez@panaderia.com',
|
||||
phone: '+34 600 456 789',
|
||||
role: 'baker',
|
||||
department: 'Producción',
|
||||
status: 'inactive',
|
||||
joinDate: '2020-11-05',
|
||||
lastLogin: '2024-01-20 16:30:00',
|
||||
permissions: ['production'],
|
||||
avatar: '/avatars/luis.jpg',
|
||||
schedule: {
|
||||
monday: '13:00-21:00',
|
||||
tuesday: '13:00-21:00',
|
||||
wednesday: '13:00-21:00',
|
||||
thursday: 'Libre',
|
||||
friday: '13:00-21:00',
|
||||
saturday: 'Libre',
|
||||
sunday: '13:00-21:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Isabel Torres',
|
||||
email: 'isabel.torres@panaderia.com',
|
||||
phone: '+34 600 567 890',
|
||||
role: 'assistant',
|
||||
department: 'Ventas',
|
||||
status: 'active',
|
||||
joinDate: '2023-06-01',
|
||||
lastLogin: '2024-01-25 18:20:00',
|
||||
permissions: ['sales'],
|
||||
avatar: '/avatars/isabel.jpg',
|
||||
schedule: {
|
||||
monday: 'Libre',
|
||||
tuesday: '16:00-20:00',
|
||||
wednesday: '16:00-20:00',
|
||||
thursday: '16:00-20:00',
|
||||
friday: '16:00-20:00',
|
||||
saturday: '14:00-20:00',
|
||||
sunday: '14:00-20:00'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
|
||||
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
|
||||
{ value: 'baker', label: 'Panadero', count: teamMembers.filter(m => m.role === 'baker').length },
|
||||
{ value: 'cashier', label: 'Cajero', count: teamMembers.filter(m => m.role === 'cashier').length },
|
||||
{ value: 'assistant', label: 'Asistente', count: teamMembers.filter(m => m.role === 'assistant').length }
|
||||
];
|
||||
|
||||
const teamStats = {
|
||||
total: teamMembers.length,
|
||||
active: teamMembers.filter(m => m.status === 'active').length,
|
||||
departments: {
|
||||
production: teamMembers.filter(m => m.department === 'Producción').length,
|
||||
sales: teamMembers.filter(m => m.department === 'Ventas').length,
|
||||
admin: teamMembers.filter(m => m.department === 'Administración').length
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'manager': return 'purple';
|
||||
case 'baker': return 'green';
|
||||
case 'cashier': return 'blue';
|
||||
case 'assistant': return 'yellow';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
return status === 'active' ? 'green' : 'red';
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'manager': return 'Gerente';
|
||||
case 'baker': return 'Panadero';
|
||||
case 'cashier': return 'Cajero';
|
||||
case 'assistant': return 'Asistente';
|
||||
default: return role;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMembers = teamMembers.filter(member => {
|
||||
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
|
||||
const matchesSearch = member.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesRole && matchesSearch;
|
||||
});
|
||||
|
||||
const formatLastLogin = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffInDays === 0) {
|
||||
return 'Hoy ' + date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffInDays === 1) {
|
||||
return 'Ayer';
|
||||
} else {
|
||||
return `hace ${diffInDays} días`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Equipo"
|
||||
description="Administra los miembros del equipo, roles y permisos"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Miembro
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Team Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Equipo</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{teamStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Activos</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{teamStats.active}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<UserCheck className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Producción</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.departments.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{teamStats.departments.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar miembros del equipo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role.value}
|
||||
onClick={() => setSelectedRole(role.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedRole === role.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
|
||||
}`}
|
||||
>
|
||||
{role.label} ({role.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Team Members List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredMembers.map((member) => (
|
||||
<Card key={member.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className="w-12 h-12 bg-[var(--bg-quaternary)] rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.name}</h3>
|
||||
<Badge variant={getStatusColor(member.status)}>
|
||||
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-3">
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{member.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
{member.phone}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Badge variant={getRoleBadgeColor(member.role)}>
|
||||
{getRoleLabel(member.role)}
|
||||
</Badge>
|
||||
<Badge variant="gray">
|
||||
{member.department}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[var(--text-tertiary)] mb-3">
|
||||
<p>Se unió: {new Date(member.joinDate).toLocaleDateString('es-ES')}</p>
|
||||
<p>Última conexión: {formatLastLogin(member.lastLogin)}</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">Permisos:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{member.permissions.map((permission, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full"
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Preview */}
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
<p className="font-medium mb-1">Horario esta semana:</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{Object.entries(member.schedule).slice(0, 4).map(([day, hours]) => (
|
||||
<span key={day}>
|
||||
{day.charAt(0).toUpperCase()}: {hours}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={member.status === 'active' ? 'text-[var(--color-error)] hover:text-[var(--color-error)]' : 'text-[var(--color-success)] hover:text-[var(--color-success)]'}
|
||||
>
|
||||
{member.status === 'active' ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Users className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se encontraron miembros</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
No hay miembros del equipo que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal Placeholder */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Nuevo Miembro del Equipo</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Formulario para agregar un nuevo miembro del equipo.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" onClick={() => setShowForm(false)}>
|
||||
Guardar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
406
frontend/src/pages/app/settings/team/TeamPage.tsx.backup
Normal file
406
frontend/src/pages/app/settings/team/TeamPage.tsx.backup
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TeamPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const teamMembers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'María González',
|
||||
email: 'maria.gonzalez@panaderia.com',
|
||||
phone: '+34 600 123 456',
|
||||
role: 'manager',
|
||||
department: 'Administración',
|
||||
status: 'active',
|
||||
joinDate: '2022-03-15',
|
||||
lastLogin: '2024-01-26 09:30:00',
|
||||
permissions: ['inventory', 'sales', 'reports', 'team'],
|
||||
avatar: '/avatars/maria.jpg',
|
||||
schedule: {
|
||||
monday: '07:00-15:00',
|
||||
tuesday: '07:00-15:00',
|
||||
wednesday: '07:00-15:00',
|
||||
thursday: '07:00-15:00',
|
||||
friday: '07:00-15:00',
|
||||
saturday: 'Libre',
|
||||
sunday: 'Libre'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Carlos Rodríguez',
|
||||
email: 'carlos.rodriguez@panaderia.com',
|
||||
phone: '+34 600 234 567',
|
||||
role: 'baker',
|
||||
department: 'Producción',
|
||||
status: 'active',
|
||||
joinDate: '2021-09-20',
|
||||
lastLogin: '2024-01-26 08:45:00',
|
||||
permissions: ['production', 'inventory'],
|
||||
avatar: '/avatars/carlos.jpg',
|
||||
schedule: {
|
||||
monday: '05:00-13:00',
|
||||
tuesday: '05:00-13:00',
|
||||
wednesday: '05:00-13:00',
|
||||
thursday: '05:00-13:00',
|
||||
friday: '05:00-13:00',
|
||||
saturday: '05:00-11:00',
|
||||
sunday: 'Libre'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Ana Martínez',
|
||||
email: 'ana.martinez@panaderia.com',
|
||||
phone: '+34 600 345 678',
|
||||
role: 'cashier',
|
||||
department: 'Ventas',
|
||||
status: 'active',
|
||||
joinDate: '2023-01-10',
|
||||
lastLogin: '2024-01-26 10:15:00',
|
||||
permissions: ['sales', 'pos'],
|
||||
avatar: '/avatars/ana.jpg',
|
||||
schedule: {
|
||||
monday: '08:00-16:00',
|
||||
tuesday: '08:00-16:00',
|
||||
wednesday: 'Libre',
|
||||
thursday: '08:00-16:00',
|
||||
friday: '08:00-16:00',
|
||||
saturday: '09:00-14:00',
|
||||
sunday: '09:00-14:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Luis Fernández',
|
||||
email: 'luis.fernandez@panaderia.com',
|
||||
phone: '+34 600 456 789',
|
||||
role: 'baker',
|
||||
department: 'Producción',
|
||||
status: 'inactive',
|
||||
joinDate: '2020-11-05',
|
||||
lastLogin: '2024-01-20 16:30:00',
|
||||
permissions: ['production'],
|
||||
avatar: '/avatars/luis.jpg',
|
||||
schedule: {
|
||||
monday: '13:00-21:00',
|
||||
tuesday: '13:00-21:00',
|
||||
wednesday: '13:00-21:00',
|
||||
thursday: 'Libre',
|
||||
friday: '13:00-21:00',
|
||||
saturday: 'Libre',
|
||||
sunday: '13:00-21:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Isabel Torres',
|
||||
email: 'isabel.torres@panaderia.com',
|
||||
phone: '+34 600 567 890',
|
||||
role: 'assistant',
|
||||
department: 'Ventas',
|
||||
status: 'active',
|
||||
joinDate: '2023-06-01',
|
||||
lastLogin: '2024-01-25 18:20:00',
|
||||
permissions: ['sales'],
|
||||
avatar: '/avatars/isabel.jpg',
|
||||
schedule: {
|
||||
monday: 'Libre',
|
||||
tuesday: '16:00-20:00',
|
||||
wednesday: '16:00-20:00',
|
||||
thursday: '16:00-20:00',
|
||||
friday: '16:00-20:00',
|
||||
saturday: '14:00-20:00',
|
||||
sunday: '14:00-20:00'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
|
||||
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
|
||||
{ value: 'baker', label: 'Panadero', count: teamMembers.filter(m => m.role === 'baker').length },
|
||||
{ value: 'cashier', label: 'Cajero', count: teamMembers.filter(m => m.role === 'cashier').length },
|
||||
{ value: 'assistant', label: 'Asistente', count: teamMembers.filter(m => m.role === 'assistant').length }
|
||||
];
|
||||
|
||||
const teamStats = {
|
||||
total: teamMembers.length,
|
||||
active: teamMembers.filter(m => m.status === 'active').length,
|
||||
departments: {
|
||||
production: teamMembers.filter(m => m.department === 'Producción').length,
|
||||
sales: teamMembers.filter(m => m.department === 'Ventas').length,
|
||||
admin: teamMembers.filter(m => m.department === 'Administración').length
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'manager': return 'purple';
|
||||
case 'baker': return 'green';
|
||||
case 'cashier': return 'blue';
|
||||
case 'assistant': return 'yellow';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
return status === 'active' ? 'green' : 'red';
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'manager': return 'Gerente';
|
||||
case 'baker': return 'Panadero';
|
||||
case 'cashier': return 'Cajero';
|
||||
case 'assistant': return 'Asistente';
|
||||
default: return role;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMembers = teamMembers.filter(member => {
|
||||
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
|
||||
const matchesSearch = member.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesRole && matchesSearch;
|
||||
});
|
||||
|
||||
const formatLastLogin = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffInDays === 0) {
|
||||
return 'Hoy ' + date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffInDays === 1) {
|
||||
return 'Ayer';
|
||||
} else {
|
||||
return `hace ${diffInDays} días`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Equipo"
|
||||
description="Administra los miembros del equipo, roles y permisos"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Miembro
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Team Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Equipo</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{teamStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Activos</p>
|
||||
<p className="text-3xl font-bold text-green-600">{teamStats.active}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<UserCheck className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Producción</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{teamStats.departments.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{teamStats.departments.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar miembros del equipo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role.value}
|
||||
onClick={() => setSelectedRole(role.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedRole === role.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{role.label} ({role.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Team Members List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredMembers.map((member) => (
|
||||
<Card key={member.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{member.name}</h3>
|
||||
<Badge variant={getStatusColor(member.status)}>
|
||||
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-3">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{member.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
{member.phone}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Badge variant={getRoleBadgeColor(member.role)}>
|
||||
{getRoleLabel(member.role)}
|
||||
</Badge>
|
||||
<Badge variant="gray">
|
||||
{member.department}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 mb-3">
|
||||
<p>Se unió: {new Date(member.joinDate).toLocaleDateString('es-ES')}</p>
|
||||
<p>Última conexión: {formatLastLogin(member.lastLogin)}</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Permisos:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{member.permissions.map((permission, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Preview */}
|
||||
<div className="text-xs text-gray-500">
|
||||
<p className="font-medium mb-1">Horario esta semana:</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{Object.entries(member.schedule).slice(0, 4).map(([day, hours]) => (
|
||||
<span key={day}>
|
||||
{day.charAt(0).toUpperCase()}: {hours}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={member.status === 'active' ? 'text-red-600 hover:text-red-700' : 'text-green-600 hover:text-green-700'}
|
||||
>
|
||||
{member.status === 'active' ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron miembros</h3>
|
||||
<p className="text-gray-600">
|
||||
No hay miembros del equipo que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal Placeholder */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nuevo Miembro del Equipo</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Formulario para agregar un nuevo miembro del equipo.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" onClick={() => setShowForm(false)}>
|
||||
Guardar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
1
frontend/src/pages/app/settings/team/index.ts
Normal file
1
frontend/src/pages/app/settings/team/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TeamPage } from './TeamPage';
|
||||
454
frontend/src/pages/app/settings/training/TrainingPage.tsx
Normal file
454
frontend/src/pages/app/settings/training/TrainingPage.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BookOpen, Play, CheckCircle, Clock, Users, Award, Download, Search } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TrainingPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const trainingModules = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Fundamentos de Panadería',
|
||||
description: 'Conceptos básicos de elaboración de pan y técnicas fundamentales',
|
||||
category: 'basics',
|
||||
duration: '2.5 horas',
|
||||
lessons: 12,
|
||||
difficulty: 'beginner',
|
||||
progress: 100,
|
||||
completed: true,
|
||||
rating: 4.8,
|
||||
instructor: 'Chef María González',
|
||||
topics: ['Ingredientes básicos', 'Proceso de amasado', 'Fermentación', 'Horneado'],
|
||||
thumbnail: '/training/bread-basics.jpg'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Técnicas Avanzadas de Bollería',
|
||||
description: 'Elaboración de croissants, hojaldre y productos fermentados complejos',
|
||||
category: 'advanced',
|
||||
duration: '4 horas',
|
||||
lessons: 18,
|
||||
difficulty: 'advanced',
|
||||
progress: 65,
|
||||
completed: false,
|
||||
rating: 4.9,
|
||||
instructor: 'Chef Pierre Laurent',
|
||||
topics: ['Masas laminadas', 'Temperaturas críticas', 'Técnicas de plegado', 'Control de calidad'],
|
||||
thumbnail: '/training/pastry-advanced.jpg'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Seguridad e Higiene Alimentaria',
|
||||
description: 'Protocolos de seguridad, HACCP y normativas sanitarias',
|
||||
category: 'safety',
|
||||
duration: '1.5 horas',
|
||||
lessons: 8,
|
||||
difficulty: 'beginner',
|
||||
progress: 0,
|
||||
completed: false,
|
||||
rating: 4.7,
|
||||
instructor: 'Dr. Ana Rodríguez',
|
||||
topics: ['HACCP', 'Limpieza y desinfección', 'Control de temperaturas', 'Trazabilidad'],
|
||||
thumbnail: '/training/food-safety.jpg'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Gestión de Inventarios',
|
||||
description: 'Optimización de stock, control de mermas y gestión de proveedores',
|
||||
category: 'management',
|
||||
duration: '3 horas',
|
||||
lessons: 15,
|
||||
difficulty: 'intermediate',
|
||||
progress: 30,
|
||||
completed: false,
|
||||
rating: 4.6,
|
||||
instructor: 'Carlos Fernández',
|
||||
topics: ['Rotación de stock', 'Punto de reorden', 'Análisis ABC', 'Negociación con proveedores'],
|
||||
thumbnail: '/training/inventory-mgmt.jpg'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Atención al Cliente',
|
||||
description: 'Técnicas de venta, resolución de quejas y fidelización',
|
||||
category: 'sales',
|
||||
duration: '2 horas',
|
||||
lessons: 10,
|
||||
difficulty: 'beginner',
|
||||
progress: 85,
|
||||
completed: false,
|
||||
rating: 4.8,
|
||||
instructor: 'Isabel Torres',
|
||||
topics: ['Técnicas de venta', 'Comunicación efectiva', 'Manejo de quejas', 'Up-selling'],
|
||||
thumbnail: '/training/customer-service.jpg'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Innovación en Productos',
|
||||
description: 'Desarrollo de nuevos productos, tendencias y análisis de mercado',
|
||||
category: 'innovation',
|
||||
duration: '3.5 horas',
|
||||
lessons: 16,
|
||||
difficulty: 'intermediate',
|
||||
progress: 0,
|
||||
completed: false,
|
||||
rating: 4.7,
|
||||
instructor: 'Chef Daniel Ramos',
|
||||
topics: ['Análisis de tendencias', 'Prototipado', 'Testing de mercado', 'Costos de producción'],
|
||||
thumbnail: '/training/product-innovation.jpg'
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todos', count: trainingModules.length },
|
||||
{ value: 'basics', label: 'Básicos', count: trainingModules.filter(m => m.category === 'basics').length },
|
||||
{ value: 'advanced', label: 'Avanzado', count: trainingModules.filter(m => m.category === 'advanced').length },
|
||||
{ value: 'safety', label: 'Seguridad', count: trainingModules.filter(m => m.category === 'safety').length },
|
||||
{ value: 'management', label: 'Gestión', count: trainingModules.filter(m => m.category === 'management').length },
|
||||
{ value: 'sales', label: 'Ventas', count: trainingModules.filter(m => m.category === 'sales').length },
|
||||
{ value: 'innovation', label: 'Innovación', count: trainingModules.filter(m => m.category === 'innovation').length }
|
||||
];
|
||||
|
||||
const teamProgress = [
|
||||
{
|
||||
name: 'María González',
|
||||
role: 'Gerente',
|
||||
completedModules: 4,
|
||||
totalModules: 6,
|
||||
currentModule: 'Gestión de Inventarios',
|
||||
progress: 75,
|
||||
certificates: 3
|
||||
},
|
||||
{
|
||||
name: 'Carlos Rodríguez',
|
||||
role: 'Panadero',
|
||||
completedModules: 2,
|
||||
totalModules: 4,
|
||||
currentModule: 'Técnicas Avanzadas de Bollería',
|
||||
progress: 65,
|
||||
certificates: 2
|
||||
},
|
||||
{
|
||||
name: 'Ana Martínez',
|
||||
role: 'Cajera',
|
||||
completedModules: 3,
|
||||
totalModules: 4,
|
||||
currentModule: 'Atención al Cliente',
|
||||
progress: 85,
|
||||
certificates: 2
|
||||
}
|
||||
];
|
||||
|
||||
const trainingStats = {
|
||||
totalModules: trainingModules.length,
|
||||
completedModules: trainingModules.filter(m => m.completed).length,
|
||||
inProgress: trainingModules.filter(m => m.progress > 0 && !m.completed).length,
|
||||
totalHours: trainingModules.reduce((sum, m) => sum + parseFloat(m.duration), 0),
|
||||
avgRating: trainingModules.reduce((sum, m) => sum + m.rating, 0) / trainingModules.length
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'green';
|
||||
case 'intermediate': return 'yellow';
|
||||
case 'advanced': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyLabel = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'Principiante';
|
||||
case 'intermediate': return 'Intermedio';
|
||||
case 'advanced': return 'Avanzado';
|
||||
default: return difficulty;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModules = trainingModules.filter(module => {
|
||||
const matchesCategory = selectedCategory === 'all' || module.category === selectedCategory;
|
||||
const matchesSearch = module.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
module.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Centro de Formación"
|
||||
description="Módulos de capacitación y desarrollo profesional para el equipo"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Certificados
|
||||
</Button>
|
||||
<Button>
|
||||
Nuevo Módulo
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Training Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Módulos Totales</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{trainingStats.totalModules}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<BookOpen className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completados</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{trainingStats.completedModules}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">En Progreso</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{trainingStats.inProgress}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Horas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{trainingStats.totalHours}h</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Rating Promedio</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">{trainingStats.avgRating.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<Award className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar módulos de formación..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Training Modules */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{filteredModules.map((module) => (
|
||||
<Card key={module.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-16 h-16 bg-[var(--bg-quaternary)] rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-8 h-8 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{module.title}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{module.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{module.completed ? (
|
||||
<Badge variant="green">Completado</Badge>
|
||||
) : module.progress > 0 ? (
|
||||
<Badge variant="blue">En Progreso</Badge>
|
||||
) : (
|
||||
<Badge variant="gray">No Iniciado</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-[var(--text-secondary)] mb-3">
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{module.duration}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<BookOpen className="w-4 h-4 mr-1" />
|
||||
{module.lessons} lecciones
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
{module.instructor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant={getDifficultyColor(module.difficulty)}>
|
||||
{getDifficultyLabel(module.difficulty)}
|
||||
</Badge>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Award className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">{module.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm text-[var(--text-secondary)] mb-1">
|
||||
<span>Progreso</span>
|
||||
<span>{module.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${module.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topics */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)] mb-2">Temas incluidos:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{module.topics.map((topic, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs rounded-full"
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{module.progress > 0 ? 'Continuar' : 'Comenzar'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Team Progress Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Progreso del Equipo</h3>
|
||||
<div className="space-y-4">
|
||||
{teamProgress.map((member, index) => (
|
||||
<div key={index} className="border-b border-[var(--border-primary)] pb-4 last:border-b-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">{member.name}</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">{member.role}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{member.completedModules}/{member.totalModules}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Award className="w-3 h-3 text-yellow-500" />
|
||||
<span className="text-xs text-[var(--text-tertiary)]">{member.certificates}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
||||
Actual: {member.currentModule}
|
||||
</p>
|
||||
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-600 h-1.5 rounded-full"
|
||||
style={{ width: `${member.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Certificaciones</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<Award className="w-5 h-5 text-[var(--color-success)]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-success)]">Certificado en Seguridad</p>
|
||||
<p className="text-xs text-[var(--color-success)]">Válido hasta: Dic 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 p-3 bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 rounded-lg">
|
||||
<Award className="w-5 h-5 text-[var(--color-info)]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-info)]">Certificado Básico</p>
|
||||
<p className="text-xs text-[var(--color-info)]">Completado: Ene 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
Ver Todos los Certificados
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainingPage;
|
||||
454
frontend/src/pages/app/settings/training/TrainingPage.tsx.backup
Normal file
454
frontend/src/pages/app/settings/training/TrainingPage.tsx.backup
Normal file
@@ -0,0 +1,454 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BookOpen, Play, CheckCircle, Clock, Users, Award, Download, Search } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TrainingPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const trainingModules = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Fundamentos de Panadería',
|
||||
description: 'Conceptos básicos de elaboración de pan y técnicas fundamentales',
|
||||
category: 'basics',
|
||||
duration: '2.5 horas',
|
||||
lessons: 12,
|
||||
difficulty: 'beginner',
|
||||
progress: 100,
|
||||
completed: true,
|
||||
rating: 4.8,
|
||||
instructor: 'Chef María González',
|
||||
topics: ['Ingredientes básicos', 'Proceso de amasado', 'Fermentación', 'Horneado'],
|
||||
thumbnail: '/training/bread-basics.jpg'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Técnicas Avanzadas de Bollería',
|
||||
description: 'Elaboración de croissants, hojaldre y productos fermentados complejos',
|
||||
category: 'advanced',
|
||||
duration: '4 horas',
|
||||
lessons: 18,
|
||||
difficulty: 'advanced',
|
||||
progress: 65,
|
||||
completed: false,
|
||||
rating: 4.9,
|
||||
instructor: 'Chef Pierre Laurent',
|
||||
topics: ['Masas laminadas', 'Temperaturas críticas', 'Técnicas de plegado', 'Control de calidad'],
|
||||
thumbnail: '/training/pastry-advanced.jpg'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Seguridad e Higiene Alimentaria',
|
||||
description: 'Protocolos de seguridad, HACCP y normativas sanitarias',
|
||||
category: 'safety',
|
||||
duration: '1.5 horas',
|
||||
lessons: 8,
|
||||
difficulty: 'beginner',
|
||||
progress: 0,
|
||||
completed: false,
|
||||
rating: 4.7,
|
||||
instructor: 'Dr. Ana Rodríguez',
|
||||
topics: ['HACCP', 'Limpieza y desinfección', 'Control de temperaturas', 'Trazabilidad'],
|
||||
thumbnail: '/training/food-safety.jpg'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Gestión de Inventarios',
|
||||
description: 'Optimización de stock, control de mermas y gestión de proveedores',
|
||||
category: 'management',
|
||||
duration: '3 horas',
|
||||
lessons: 15,
|
||||
difficulty: 'intermediate',
|
||||
progress: 30,
|
||||
completed: false,
|
||||
rating: 4.6,
|
||||
instructor: 'Carlos Fernández',
|
||||
topics: ['Rotación de stock', 'Punto de reorden', 'Análisis ABC', 'Negociación con proveedores'],
|
||||
thumbnail: '/training/inventory-mgmt.jpg'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Atención al Cliente',
|
||||
description: 'Técnicas de venta, resolución de quejas y fidelización',
|
||||
category: 'sales',
|
||||
duration: '2 horas',
|
||||
lessons: 10,
|
||||
difficulty: 'beginner',
|
||||
progress: 85,
|
||||
completed: false,
|
||||
rating: 4.8,
|
||||
instructor: 'Isabel Torres',
|
||||
topics: ['Técnicas de venta', 'Comunicación efectiva', 'Manejo de quejas', 'Up-selling'],
|
||||
thumbnail: '/training/customer-service.jpg'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Innovación en Productos',
|
||||
description: 'Desarrollo de nuevos productos, tendencias y análisis de mercado',
|
||||
category: 'innovation',
|
||||
duration: '3.5 horas',
|
||||
lessons: 16,
|
||||
difficulty: 'intermediate',
|
||||
progress: 0,
|
||||
completed: false,
|
||||
rating: 4.7,
|
||||
instructor: 'Chef Daniel Ramos',
|
||||
topics: ['Análisis de tendencias', 'Prototipado', 'Testing de mercado', 'Costos de producción'],
|
||||
thumbnail: '/training/product-innovation.jpg'
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todos', count: trainingModules.length },
|
||||
{ value: 'basics', label: 'Básicos', count: trainingModules.filter(m => m.category === 'basics').length },
|
||||
{ value: 'advanced', label: 'Avanzado', count: trainingModules.filter(m => m.category === 'advanced').length },
|
||||
{ value: 'safety', label: 'Seguridad', count: trainingModules.filter(m => m.category === 'safety').length },
|
||||
{ value: 'management', label: 'Gestión', count: trainingModules.filter(m => m.category === 'management').length },
|
||||
{ value: 'sales', label: 'Ventas', count: trainingModules.filter(m => m.category === 'sales').length },
|
||||
{ value: 'innovation', label: 'Innovación', count: trainingModules.filter(m => m.category === 'innovation').length }
|
||||
];
|
||||
|
||||
const teamProgress = [
|
||||
{
|
||||
name: 'María González',
|
||||
role: 'Gerente',
|
||||
completedModules: 4,
|
||||
totalModules: 6,
|
||||
currentModule: 'Gestión de Inventarios',
|
||||
progress: 75,
|
||||
certificates: 3
|
||||
},
|
||||
{
|
||||
name: 'Carlos Rodríguez',
|
||||
role: 'Panadero',
|
||||
completedModules: 2,
|
||||
totalModules: 4,
|
||||
currentModule: 'Técnicas Avanzadas de Bollería',
|
||||
progress: 65,
|
||||
certificates: 2
|
||||
},
|
||||
{
|
||||
name: 'Ana Martínez',
|
||||
role: 'Cajera',
|
||||
completedModules: 3,
|
||||
totalModules: 4,
|
||||
currentModule: 'Atención al Cliente',
|
||||
progress: 85,
|
||||
certificates: 2
|
||||
}
|
||||
];
|
||||
|
||||
const trainingStats = {
|
||||
totalModules: trainingModules.length,
|
||||
completedModules: trainingModules.filter(m => m.completed).length,
|
||||
inProgress: trainingModules.filter(m => m.progress > 0 && !m.completed).length,
|
||||
totalHours: trainingModules.reduce((sum, m) => sum + parseFloat(m.duration), 0),
|
||||
avgRating: trainingModules.reduce((sum, m) => sum + m.rating, 0) / trainingModules.length
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'green';
|
||||
case 'intermediate': return 'yellow';
|
||||
case 'advanced': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyLabel = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'Principiante';
|
||||
case 'intermediate': return 'Intermedio';
|
||||
case 'advanced': return 'Avanzado';
|
||||
default: return difficulty;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModules = trainingModules.filter(module => {
|
||||
const matchesCategory = selectedCategory === 'all' || module.category === selectedCategory;
|
||||
const matchesSearch = module.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
module.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Centro de Formación"
|
||||
description="Módulos de capacitación y desarrollo profesional para el equipo"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Certificados
|
||||
</Button>
|
||||
<Button>
|
||||
Nuevo Módulo
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Training Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Módulos Totales</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{trainingStats.totalModules}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<BookOpen className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completados</p>
|
||||
<p className="text-3xl font-bold text-green-600">{trainingStats.completedModules}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">En Progreso</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{trainingStats.inProgress}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Horas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{trainingStats.totalHours}h</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Rating Promedio</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">{trainingStats.avgRating.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<Award className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar módulos de formación..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Training Modules */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{filteredModules.map((module) => (
|
||||
<Card key={module.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{module.title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">{module.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{module.completed ? (
|
||||
<Badge variant="green">Completado</Badge>
|
||||
) : module.progress > 0 ? (
|
||||
<Badge variant="blue">En Progreso</Badge>
|
||||
) : (
|
||||
<Badge variant="gray">No Iniciado</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{module.duration}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<BookOpen className="w-4 h-4 mr-1" />
|
||||
{module.lessons} lecciones
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
{module.instructor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant={getDifficultyColor(module.difficulty)}>
|
||||
{getDifficultyLabel(module.difficulty)}
|
||||
</Badge>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Award className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium text-gray-700">{module.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Progreso</span>
|
||||
<span>{module.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${module.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topics */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Temas incluidos:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{module.topics.map((topic, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full"
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{module.progress > 0 ? 'Continuar' : 'Comenzar'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Team Progress Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Progreso del Equipo</h3>
|
||||
<div className="space-y-4">
|
||||
{teamProgress.map((member, index) => (
|
||||
<div key={index} className="border-b border-gray-200 pb-4 last:border-b-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{member.name}</p>
|
||||
<p className="text-sm text-gray-500">{member.role}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{member.completedModules}/{member.totalModules}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Award className="w-3 h-3 text-yellow-500" />
|
||||
<span className="text-xs text-gray-500">{member.certificates}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
Actual: {member.currentModule}
|
||||
</p>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-600 h-1.5 rounded-full"
|
||||
style={{ width: `${member.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Certificaciones</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<Award className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-800">Certificado en Seguridad</p>
|
||||
<p className="text-xs text-green-600">Válido hasta: Dic 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Award className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Certificado Básico</p>
|
||||
<p className="text-xs text-blue-600">Completado: Ene 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
Ver Todos los Certificados
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainingPage;
|
||||
1
frontend/src/pages/app/settings/training/index.ts
Normal file
1
frontend/src/pages/app/settings/training/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TrainingPage } from './TrainingPage';
|
||||
@@ -1,295 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { loginSuccess } from '../../store/slices/authSlice';
|
||||
|
||||
import {
|
||||
useAuth,
|
||||
LoginRequest
|
||||
} from '../../api';
|
||||
|
||||
interface LoginPageProps {
|
||||
// No props needed with React Router
|
||||
}
|
||||
|
||||
interface LoginForm {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { login, isLoading, isAuthenticated } = useAuth();
|
||||
|
||||
// Get the intended destination from state, default to app
|
||||
const from = (location.state as any)?.from?.pathname || '/app';
|
||||
|
||||
const [formData, setFormData] = useState<LoginForm>({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<LoginForm>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<LoginForm> = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'El email es obligatorio';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'El email no es válido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es obligatoria';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
|
||||
try {
|
||||
|
||||
const loginData: LoginRequest = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
await login(loginData);
|
||||
|
||||
toast.success('¡Bienvenido a PanIA!');
|
||||
|
||||
const userData = localStorage.getItem('user_data');
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
if (userData && token) {
|
||||
const user = JSON.parse(userData);
|
||||
|
||||
// Set auth state
|
||||
dispatch(loginSuccess({ user, token }));
|
||||
|
||||
// Navigate to intended destination
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
toast.error(error.message || 'Error al iniciar sesión');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof LoginForm]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||
PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Inteligencia Artificial para tu Panadería
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Inicia sesión para acceder a tus predicciones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-700">
|
||||
Recordarme
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a
|
||||
href="#"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Iniciando sesión...
|
||||
</>
|
||||
) : (
|
||||
'Iniciar sesión'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿No tienes una cuenta?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Regístrate gratis
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Preview */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Más de 500 panaderías en Madrid confían en PanIA
|
||||
</p>
|
||||
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Predicciones precisas
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Reduce desperdicios
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Fácil de usar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,774 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Eye, EyeOff, Loader2, Check, CreditCard, Shield, ArrowRight } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe,
|
||||
useElements
|
||||
} from '@stripe/react-stripe-js';
|
||||
|
||||
import {
|
||||
useAuth,
|
||||
RegisterRequest
|
||||
} from '../../api';
|
||||
|
||||
// Development flags
|
||||
const isDevelopment = import.meta.env.DEV;
|
||||
const bypassPayment = import.meta.env.VITE_BYPASS_PAYMENT === 'true';
|
||||
|
||||
// Initialize Stripe with Spanish market configuration (only if not bypassing)
|
||||
const stripePromise = !bypassPayment ? loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '', {
|
||||
locale: 'es'
|
||||
}) : null;
|
||||
|
||||
// Stripe card element options for Spanish market
|
||||
const cardElementOptions = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#374151',
|
||||
'::placeholder': {
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#EF4444',
|
||||
},
|
||||
},
|
||||
hidePostalCode: false, // Keep postal code for better fraud protection
|
||||
};
|
||||
|
||||
// Subscription pricing (monthly)
|
||||
const SUBSCRIPTION_PRICE_EUR = 29.99;
|
||||
|
||||
interface RegisterPageProps {
|
||||
onLogin: (user: any, token: string) => void;
|
||||
onNavigateToLogin: () => void;
|
||||
}
|
||||
|
||||
interface RegisterForm {
|
||||
fullName: string;
|
||||
email: string;
|
||||
confirmEmail: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
paymentCompleted: boolean;
|
||||
}
|
||||
|
||||
interface RegisterFormErrors {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
confirmEmail?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
acceptTerms?: string;
|
||||
paymentCompleted?: string;
|
||||
}
|
||||
|
||||
const RegisterPage: React.FC<RegisterPageProps> = ({ onLogin, onNavigateToLogin }) => {
|
||||
const { register, isLoading } = useAuth();
|
||||
|
||||
const [formData, setFormData] = useState<RegisterForm>({
|
||||
fullName: '',
|
||||
email: '',
|
||||
confirmEmail: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
paymentCompleted: false
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<RegisterFormErrors>({});
|
||||
const [passwordStrength, setPasswordStrength] = useState<{
|
||||
score: number;
|
||||
checks: { [key: string]: boolean };
|
||||
message: string;
|
||||
}>({ score: 0, checks: {}, message: '' });
|
||||
const [paymentStep, setPaymentStep] = useState<'form' | 'payment' | 'processing' | 'completed'>('form');
|
||||
const [paymentLoading, setPaymentLoading] = useState(false);
|
||||
|
||||
// Update password strength in real-time
|
||||
useEffect(() => {
|
||||
if (formData.password) {
|
||||
const validation = validatePassword(formData.password);
|
||||
setPasswordStrength(validation);
|
||||
}
|
||||
}, [formData.password]);
|
||||
|
||||
// Payment processing component
|
||||
const PaymentForm: React.FC<{ onPaymentSuccess: () => void }> = ({ onPaymentSuccess }) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const handlePayment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
toast.error('Stripe no está cargado correctamente');
|
||||
return;
|
||||
}
|
||||
|
||||
const card = elements.getElement(CardElement);
|
||||
if (!card) {
|
||||
toast.error('Elemento de tarjeta no encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentLoading(true);
|
||||
|
||||
try {
|
||||
// Create payment method
|
||||
const { error } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card,
|
||||
billing_details: {
|
||||
name: formData.fullName,
|
||||
email: formData.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// Here you would typically create the subscription via your backend
|
||||
// For now, we'll simulate a successful payment
|
||||
toast.success('¡Pago procesado correctamente!');
|
||||
|
||||
setFormData(prev => ({ ...prev, paymentCompleted: true }));
|
||||
// Skip intermediate page and proceed directly to registration
|
||||
onPaymentSuccess();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Error procesando el pago');
|
||||
} finally {
|
||||
setPaymentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handlePayment} className="space-y-6">
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-xl p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-10 h-10 bg-primary-500 rounded-full flex items-center justify-center">
|
||||
<CreditCard className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Suscripción PanIA Pro</h3>
|
||||
<p className="text-sm text-gray-600">Facturación mensual</p>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-2xl font-bold text-primary-600">€{SUBSCRIPTION_PRICE_EUR}</div>
|
||||
<div className="text-sm text-gray-500">/mes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Predicciones de demanda ilimitadas
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Análisis de tendencias avanzado
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Soporte técnico prioritario
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Integración con sistemas de punto de venta
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Información de la tarjeta
|
||||
</span>
|
||||
<div className="border border-gray-300 rounded-xl p-4 focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-primary-500">
|
||||
<CardElement options={cardElementOptions} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Pago seguro con encriptación SSL. Powered by Stripe.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || paymentLoading}
|
||||
className="w-full bg-primary-500 text-white py-3 px-4 rounded-xl font-medium hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
|
||||
>
|
||||
{paymentLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Procesando pago...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-5 h-5 mr-2" />
|
||||
Pagar €{SUBSCRIPTION_PRICE_EUR}/mes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// Password validation based on backend rules
|
||||
const validatePassword = (password: string) => {
|
||||
const checks = {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
numbers: /\d/.test(password),
|
||||
// symbols: /[!@#$%^&*(),.?":{}|<>]/.test(password) // Backend doesn't require symbols
|
||||
};
|
||||
|
||||
const score = Object.values(checks).filter(Boolean).length;
|
||||
|
||||
let message = '';
|
||||
if (score < 4) {
|
||||
if (!checks.length) message += 'Mínimo 8 caracteres. ';
|
||||
if (!checks.uppercase) message += 'Una mayúscula. ';
|
||||
if (!checks.lowercase) message += 'Una minúscula. ';
|
||||
if (!checks.numbers) message += 'Un número. ';
|
||||
} else {
|
||||
message = '¡Contraseña segura!';
|
||||
}
|
||||
|
||||
return { score, checks, message: message.trim() };
|
||||
};
|
||||
|
||||
|
||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form but exclude payment requirement for first step
|
||||
const newErrors: RegisterFormErrors = {};
|
||||
|
||||
if (!formData.fullName.trim()) {
|
||||
newErrors.fullName = 'El nombre completo es obligatorio';
|
||||
} else if (formData.fullName.trim().length < 2) {
|
||||
newErrors.fullName = 'El nombre debe tener al menos 2 caracteres';
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'El email es obligatorio';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'El email no es válido';
|
||||
}
|
||||
|
||||
if (!formData.confirmEmail) {
|
||||
newErrors.confirmEmail = 'Confirma tu email';
|
||||
} else if (formData.email !== formData.confirmEmail) {
|
||||
newErrors.confirmEmail = 'Los emails no coinciden';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es obligatoria';
|
||||
} else {
|
||||
const passwordValidation = validatePassword(formData.password);
|
||||
if (passwordValidation.score < 4) {
|
||||
newErrors.password = passwordValidation.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
}
|
||||
|
||||
if (!formData.acceptTerms) {
|
||||
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
// Move to payment step, or bypass if in development mode
|
||||
if (bypassPayment) {
|
||||
// Development bypass: simulate payment completion and proceed directly to registration
|
||||
setFormData(prev => ({ ...prev, paymentCompleted: true }));
|
||||
toast.success('🚀 Modo desarrollo: Pago omitido');
|
||||
// Proceed directly to registration without intermediate page
|
||||
handleRegistrationComplete();
|
||||
} else {
|
||||
setPaymentStep('payment');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationComplete = async () => {
|
||||
if (!bypassPayment && !formData.paymentCompleted) {
|
||||
toast.error('El pago debe completarse antes del registro');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registerData: RegisterRequest = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
full_name: formData.fullName,
|
||||
role: 'user' // Default role
|
||||
};
|
||||
|
||||
await register(registerData);
|
||||
|
||||
toast.success('¡Registro exitoso! Bienvenido a PanIA');
|
||||
|
||||
// The useAuth hook handles auto-login after registration
|
||||
// Get the user data from localStorage since useAuth auto-logs in
|
||||
const userData = localStorage.getItem('user_data');
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
if (userData && token) {
|
||||
onLogin(JSON.parse(userData), token);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Error en el registro');
|
||||
// Reset payment if registration fails
|
||||
setFormData(prev => ({ ...prev, paymentCompleted: false }));
|
||||
setPaymentStep('payment');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof RegisterFormErrors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Render different content based on payment step
|
||||
if (paymentStep === 'payment' && !bypassPayment) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||
Finalizar Registro
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Solo un paso más para comenzar
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Suscripción segura con Stripe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<PaymentForm onPaymentSuccess={handleRegistrationComplete} />
|
||||
|
||||
<button
|
||||
onClick={() => setPaymentStep('form')}
|
||||
className="w-full mt-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
← Volver al formulario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
if (paymentStep === 'completed') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-green-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<Check className="text-white text-2xl" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||
¡Bienvenido a PanIA!
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Tu cuenta ha sido creada exitosamente
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Redirigiendo al panel de control...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||
Únete a PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Crea tu cuenta y transforma tu panadería
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Únete a más de 500 panaderías en Madrid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<form className="space-y-6" onSubmit={handleFormSubmit}>
|
||||
{/* Full Name Field */}
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre completo
|
||||
</label>
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.fullName
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Email Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmEmail" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="confirmEmail"
|
||||
name="confirmEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.confirmEmail}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.confirmEmail
|
||||
? 'border-red-300 bg-red-50'
|
||||
: formData.confirmEmail && formData.email === formData.confirmEmail
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
{formData.confirmEmail && formData.email === formData.confirmEmail && (
|
||||
<div className="absolute inset-y-0 right-3 flex items-center mt-8">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
)}
|
||||
{errors.confirmEmail && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmEmail}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Password Strength Indicator */}
|
||||
{formData.password && (
|
||||
<div className="mt-2">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{Object.entries(passwordStrength.checks).map(([key, passed], index) => {
|
||||
const labels = {
|
||||
length: '8+ caracteres',
|
||||
uppercase: 'Mayúscula',
|
||||
lowercase: 'Minúscula',
|
||||
numbers: 'Número'
|
||||
};
|
||||
return (
|
||||
<div key={key} className={`text-xs p-1 rounded text-center ${
|
||||
passed ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{passed ? '✓' : '○'} {labels[key as keyof typeof labels]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${
|
||||
passwordStrength.score === 4 ? 'text-green-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{passwordStrength.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.confirmPassword
|
||||
? 'border-red-300 bg-red-50'
|
||||
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||
<div className="absolute inset-y-0 right-10 flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
name="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
política de privacidad
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Validando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{bypassPayment ? 'Crear Cuenta (Dev)' : 'Continuar al Pago'}
|
||||
<ArrowRight className="h-5 w-5 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿Ya tienes una cuenta?{' '}
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Inicia sesión aquí
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="text-center">
|
||||
{bypassPayment && (
|
||||
<div className="mb-4 p-2 bg-yellow-100 border border-yellow-300 rounded-lg">
|
||||
<p className="text-xs text-yellow-800">
|
||||
🚀 Modo Desarrollo: Pago omitido para pruebas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
{bypassPayment
|
||||
? 'Desarrollo • Pruebas • Sin pago requerido'
|
||||
: 'Proceso seguro • Cancela en cualquier momento • Soporte 24/7'
|
||||
}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-gray-400">
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Predicciones IA
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Análisis de demanda
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Reduce desperdicios
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -1,429 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Eye, EyeOff, Loader2, User, Mail, Lock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { loginSuccess } from '../../store/slices/authSlice';
|
||||
import { authService } from '../../api/services/auth.service';
|
||||
import { onboardingService } from '../../api/services/onboarding.service';
|
||||
import type { RegisterRequest } from '../../api/types/auth';
|
||||
|
||||
interface RegisterForm {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
interface RegisterFormErrors {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
acceptTerms?: string;
|
||||
}
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [formData, setFormData] = useState<RegisterForm>({
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<RegisterFormErrors>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: RegisterFormErrors = {};
|
||||
|
||||
if (!formData.fullName.trim()) {
|
||||
newErrors.fullName = 'El nombre es obligatorio';
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'El email es obligatorio';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'El email no es válido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es obligatoria';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
}
|
||||
|
||||
if (!formData.acceptTerms) {
|
||||
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare registration data
|
||||
const registrationData: RegisterRequest = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
full_name: formData.fullName,
|
||||
role: 'admin',
|
||||
language: 'es'
|
||||
};
|
||||
|
||||
// Call real authentication API
|
||||
const response = await authService.register(registrationData);
|
||||
|
||||
// Extract user data from response
|
||||
const userData = response.user;
|
||||
if (!userData) {
|
||||
throw new Error('No se recibieron datos del usuario');
|
||||
}
|
||||
|
||||
// Convert API response to internal format
|
||||
const user = {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
fullName: userData.full_name,
|
||||
role: (userData.role as "owner" | "admin" | "manager" | "worker") || 'admin',
|
||||
isOnboardingComplete: false, // New users need onboarding
|
||||
tenant_id: userData.tenant_id
|
||||
};
|
||||
|
||||
// Store tokens in localStorage
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
if (response.refresh_token) {
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
}
|
||||
localStorage.setItem('user_data', JSON.stringify(user));
|
||||
|
||||
// Set auth state
|
||||
dispatch(loginSuccess({ user, token: response.access_token }));
|
||||
|
||||
// Mark user_registered step as completed in onboarding
|
||||
try {
|
||||
await onboardingService.completeStep('user_registered', {
|
||||
user_id: userData.id,
|
||||
registration_completed_at: new Date().toISOString(),
|
||||
registration_method: 'web_form'
|
||||
});
|
||||
console.log('✅ user_registered step marked as completed');
|
||||
} catch (onboardingError) {
|
||||
console.warn('Failed to mark user_registered step as completed:', onboardingError);
|
||||
// Don't block the flow if onboarding step completion fails
|
||||
}
|
||||
|
||||
toast.success('¡Cuenta creada exitosamente!');
|
||||
|
||||
// Navigate to onboarding
|
||||
navigate('/app/onboarding');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Error al crear la cuenta';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof RegisterForm]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Únete a PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Crea tu cuenta y comienza a optimizar tu panadería
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Full Name Field */}
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre completo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.fullName
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="Juan Pérez"
|
||||
/>
|
||||
</div>
|
||||
{errors.fullName && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.confirmPassword
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
name="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="text-primary-600 hover:text-primary-500">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="#" className="text-primary-600 hover:text-primary-500">
|
||||
política de privacidad
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Creando cuenta...
|
||||
</>
|
||||
) : (
|
||||
'Crear cuenta'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿Ya tienes una cuenta?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Inicia sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Preview */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Prueba gratuita de 14 días • No se requiere tarjeta de crédito
|
||||
</p>
|
||||
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Setup en 5 minutos
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Soporte incluido
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Cancela cuando quieras
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -1,247 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDashboard } from '../../hooks/useDashboard';
|
||||
import { useOrderSuggestions } from '../../hooks/useOrderSuggestions';
|
||||
|
||||
// Import simplified components for fallback
|
||||
import TodayRevenue from '../../components/simple/TodayRevenue';
|
||||
import TodayProduction from '../../components/simple/TodayProduction';
|
||||
import QuickActions from '../../components/simple/QuickActions';
|
||||
import QuickOverview from '../../components/simple/QuickOverview';
|
||||
import OrderSuggestions from '../../components/simple/OrderSuggestions';
|
||||
import WeatherContext from '../../components/simple/WeatherContext';
|
||||
|
||||
interface DashboardPageProps {
|
||||
onNavigateToOrders?: () => void;
|
||||
onNavigateToReports?: () => void;
|
||||
onNavigateToProduction?: () => void;
|
||||
onNavigateToInventory?: () => void;
|
||||
onNavigateToRecipes?: () => void;
|
||||
onNavigateToSales?: () => void;
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC<DashboardPageProps> = ({
|
||||
onNavigateToOrders,
|
||||
onNavigateToReports,
|
||||
onNavigateToProduction,
|
||||
onNavigateToInventory,
|
||||
onNavigateToRecipes,
|
||||
onNavigateToSales
|
||||
}) => {
|
||||
const {
|
||||
weather,
|
||||
tenantId,
|
||||
isLoading,
|
||||
error,
|
||||
reload,
|
||||
todayForecasts,
|
||||
metrics
|
||||
} = useDashboard();
|
||||
|
||||
// Use real API data for order suggestions - pass tenantId from dashboard
|
||||
const {
|
||||
dailyOrders: realDailyOrders,
|
||||
weeklyOrders: realWeeklyOrders,
|
||||
isLoading: ordersLoading,
|
||||
error: ordersError
|
||||
} = useOrderSuggestions(tenantId);
|
||||
|
||||
// Debug order suggestions
|
||||
console.log('📈 Dashboard: OrderSuggestions data:', {
|
||||
dailyOrders: realDailyOrders,
|
||||
weeklyOrders: realWeeklyOrders,
|
||||
isLoading: ordersLoading,
|
||||
error: ordersError
|
||||
});
|
||||
|
||||
|
||||
// Transform forecast data for production component
|
||||
|
||||
const mockProduction = todayForecasts.map((forecast, index) => ({
|
||||
id: `prod-${index}`,
|
||||
product: forecast.product,
|
||||
emoji: forecast.product.toLowerCase().includes('croissant') ? '🥐' :
|
||||
forecast.product.toLowerCase().includes('pan') ? '🍞' :
|
||||
forecast.product.toLowerCase().includes('magdalena') ? '🧁' : '🥖',
|
||||
quantity: forecast.predicted,
|
||||
status: 'pending' as const,
|
||||
scheduledTime: index < 3 ? '06:00' : '14:00',
|
||||
confidence: forecast.confidence === 'high' ? 0.9 :
|
||||
forecast.confidence === 'medium' ? 0.7 : 0.5
|
||||
}));
|
||||
|
||||
|
||||
// Helper function for greeting
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Buenos días';
|
||||
if (hour < 18) return 'Buenas tardes';
|
||||
return 'Buenas noches';
|
||||
};
|
||||
|
||||
// Classic dashboard view
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando datos de tu panadería...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-red-800 font-medium">Error al cargar datos</h3>
|
||||
<p className="text-red-700 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6 bg-gray-50 min-h-screen">
|
||||
|
||||
{/* Welcome Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{getGreeting()}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 flex items-center space-x-4">
|
||||
{weather && (
|
||||
<div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2">
|
||||
<span className="text-lg mr-2">
|
||||
{weather.precipitation && weather.precipitation > 0 ? '🌧️' : weather.temperature && weather.temperature > 20 ? '☀️' : '⛅'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span>{weather.temperature?.toFixed(1) || '--'}°C</span>
|
||||
<span className="text-xs text-gray-500">AEMET</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">Estado del sistema</div>
|
||||
<div className="text-xs text-green-600 flex items-center">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-1"></div>
|
||||
Operativo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Section - Always Visible */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Revenue - Most Important */}
|
||||
<TodayRevenue
|
||||
currentRevenue={metrics?.totalSales || 287.50}
|
||||
previousRevenue={256.25}
|
||||
dailyTarget={350}
|
||||
/>
|
||||
|
||||
|
||||
{/* Quick Actions - Easy Access */}
|
||||
<QuickActions
|
||||
onActionClick={(actionId) => {
|
||||
console.log('Action clicked:', actionId);
|
||||
// Handle quick actions
|
||||
switch (actionId) {
|
||||
case 'view_orders':
|
||||
onNavigateToOrders?.();
|
||||
break;
|
||||
case 'view_sales':
|
||||
onNavigateToReports?.();
|
||||
break;
|
||||
default:
|
||||
// Handle other actions
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order Suggestions - Real AI-Powered Recommendations */}
|
||||
{ordersLoading ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<span className="ml-3 text-gray-600">Cargando sugerencias de pedidos...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : ordersError ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<h3 className="text-red-800 font-medium">Error al cargar sugerencias</h3>
|
||||
<p className="text-red-700 mt-1">{ordersError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<OrderSuggestions
|
||||
dailyOrders={realDailyOrders}
|
||||
weeklyOrders={realWeeklyOrders}
|
||||
onUpdateQuantity={(orderId, quantity, type) => {
|
||||
console.log('Update order quantity:', orderId, quantity, type);
|
||||
// In real implementation, this would update the backend
|
||||
}}
|
||||
onCreateOrder={(items, type) => {
|
||||
console.log('Create order:', type, items);
|
||||
// Navigate to orders page to complete the order
|
||||
onNavigateToOrders?.();
|
||||
}}
|
||||
onViewDetails={() => {
|
||||
onNavigateToOrders?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Weather & Context - Comprehensive AEMET Data */}
|
||||
<WeatherContext />
|
||||
|
||||
{/* Production Section - Core Operations */}
|
||||
<TodayProduction
|
||||
items={mockProduction}
|
||||
onUpdateQuantity={(itemId: string, quantity: number) => {
|
||||
console.log('Update quantity:', itemId, quantity);
|
||||
}}
|
||||
onUpdateStatus={(itemId: string, status: any) => {
|
||||
console.log('Update status:', itemId, status);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quick Overview - Supporting Information */}
|
||||
<QuickOverview
|
||||
onNavigateToOrders={onNavigateToOrders}
|
||||
onNavigateToReports={onNavigateToReports}
|
||||
/>
|
||||
|
||||
|
||||
{/* Success Message - When Everything is Good */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
|
||||
<div className="text-4xl mb-2">🎉</div>
|
||||
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
|
||||
<p className="text-green-700 text-sm mt-1">
|
||||
Tu panadería está funcionando perfectamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,683 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Calendar, Cloud, AlertTriangle, Info, RefreshCw } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { useForecast } from '../../api/hooks/useForecast';
|
||||
import { useInventoryProducts } from '../../api/hooks/useInventory';
|
||||
import { useTenantId } from '../../hooks/useTenantId';
|
||||
import type { ForecastResponse } from '../../api/types/forecasting';
|
||||
|
||||
interface ForecastData {
|
||||
date: string;
|
||||
product: string;
|
||||
predicted: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
factors: string[];
|
||||
weatherImpact?: string;
|
||||
inventory_product_id?: string;
|
||||
confidence_lower?: number;
|
||||
confidence_upper?: number;
|
||||
}
|
||||
|
||||
interface WeatherAlert {
|
||||
type: 'rain' | 'heat' | 'cold';
|
||||
impact: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
const ForecastPage: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||
const [forecastData, setForecastData] = useState<ForecastData[]>([]);
|
||||
const [weatherAlert, setWeatherAlert] = useState<WeatherAlert | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<any[]>([]);
|
||||
|
||||
// Hooks
|
||||
const { tenantId } = useTenantId();
|
||||
const {
|
||||
forecasts,
|
||||
isLoading: forecastLoading,
|
||||
error: forecastError,
|
||||
createSingleForecast,
|
||||
getForecasts,
|
||||
getForecastAlerts,
|
||||
exportForecasts
|
||||
} = useForecast();
|
||||
const {
|
||||
getProductsList,
|
||||
isLoading: inventoryLoading,
|
||||
error: inventoryError
|
||||
} = useInventoryProducts();
|
||||
|
||||
// Debug logging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('ForecastPage - inventoryItems:', inventoryItems);
|
||||
console.log('ForecastPage - inventoryLoading:', inventoryLoading);
|
||||
console.log('ForecastPage - tenantId:', tenantId);
|
||||
}
|
||||
|
||||
// Derived state
|
||||
const isLoading = forecastLoading || inventoryLoading;
|
||||
const products = (inventoryItems || []).map(item => ({
|
||||
id: item.id,
|
||||
name: item.name || 'Unknown Product'
|
||||
}));
|
||||
|
||||
// Sample forecast data for the next 7 days - will be populated by real data
|
||||
const [sampleForecastData, setSampleForecastData] = useState<any[]>(() => {
|
||||
// Generate 7 days starting from today
|
||||
const data = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + i);
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
croissants: 0,
|
||||
pan: 0,
|
||||
cafe: 0
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
// Load inventory items on component mount
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
if (tenantId) {
|
||||
try {
|
||||
const products = await getProductsList(tenantId);
|
||||
|
||||
// Always use real inventory products - no hardcoded fallbacks
|
||||
if (products.length === 0) {
|
||||
console.warn('⚠️ No finished products found in inventory for forecasting');
|
||||
setInventoryItems([]);
|
||||
} else {
|
||||
// Map products to the expected format
|
||||
setInventoryItems(products.map(p => ({
|
||||
id: p.inventory_product_id,
|
||||
name: p.name
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load products:', error);
|
||||
// Don't use fake fallback products - show empty state instead
|
||||
setInventoryItems([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
}, [tenantId, getProductsList]);
|
||||
|
||||
// Transform API forecasts to our local format
|
||||
const transformForecastResponse = (forecast: ForecastResponse): ForecastData => {
|
||||
// Find product name from inventory items
|
||||
const inventoryItem = (inventoryItems || []).find(item => item.id === forecast.inventory_product_id);
|
||||
const productName = inventoryItem?.name || 'Unknown Product';
|
||||
|
||||
// Determine confidence level based on confidence_level number
|
||||
let confidence: 'high' | 'medium' | 'low' = 'medium';
|
||||
if (forecast.confidence_level) {
|
||||
if (forecast.confidence_level >= 0.8) confidence = 'high';
|
||||
else if (forecast.confidence_level >= 0.6) confidence = 'medium';
|
||||
else confidence = 'low';
|
||||
}
|
||||
|
||||
// Extract factors from features_used or provide defaults
|
||||
const factors = [];
|
||||
if (forecast.features_used) {
|
||||
if (forecast.features_used.is_weekend === false) factors.push('Día laboral');
|
||||
else if (forecast.features_used.is_weekend === true) factors.push('Fin de semana');
|
||||
|
||||
if (forecast.features_used.is_holiday === false) factors.push('Sin eventos especiales');
|
||||
else if (forecast.features_used.is_holiday === true) factors.push('Día festivo');
|
||||
|
||||
if (forecast.features_used.weather_description) factors.push(`Clima: ${forecast.features_used.weather_description}`);
|
||||
else factors.push('Clima estable');
|
||||
} else {
|
||||
factors.push('Día laboral', 'Clima estable', 'Sin eventos especiales');
|
||||
}
|
||||
|
||||
// Determine weather impact
|
||||
let weatherImpact = 'Sin impacto significativo';
|
||||
if (forecast.features_used?.temperature) {
|
||||
const temp = forecast.features_used.temperature;
|
||||
if (temp < 10) weatherImpact = 'Temperatura baja - posible aumento en bebidas calientes';
|
||||
else if (temp > 25) weatherImpact = 'Temperatura alta - posible reducción en productos horneados';
|
||||
}
|
||||
|
||||
return {
|
||||
date: forecast.forecast_date.split('T')[0], // Convert to YYYY-MM-DD
|
||||
product: productName,
|
||||
predicted: Math.round(forecast.predicted_demand),
|
||||
confidence,
|
||||
factors,
|
||||
weatherImpact,
|
||||
inventory_product_id: forecast.inventory_product_id,
|
||||
confidence_lower: forecast.confidence_lower,
|
||||
confidence_upper: forecast.confidence_upper,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate forecasts for available products
|
||||
const generateForecasts = async () => {
|
||||
if (!tenantId || !inventoryItems || inventoryItems.length === 0) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Generate forecasts for products with trained models only
|
||||
const productsToForecast = inventoryItems;
|
||||
const chartData = [];
|
||||
|
||||
// Generate data for the next 7 days
|
||||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||
const forecastDate = new Date();
|
||||
forecastDate.setDate(forecastDate.getDate() + dayOffset);
|
||||
const dateStr = forecastDate.toISOString().split('T')[0];
|
||||
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
croissants: 0,
|
||||
pan: 0,
|
||||
cafe: 0
|
||||
};
|
||||
|
||||
// Generate forecasts for each product for this day
|
||||
const dayForecasts = await Promise.all(
|
||||
productsToForecast.map(async (item) => {
|
||||
try {
|
||||
const forecastResponses = await createSingleForecast(tenantId, {
|
||||
inventory_product_id: item.id,
|
||||
forecast_date: dateStr,
|
||||
forecast_days: 1,
|
||||
location: 'Madrid, Spain',
|
||||
include_external_factors: true,
|
||||
confidence_intervals: true,
|
||||
});
|
||||
|
||||
return forecastResponses.map(transformForecastResponse);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate forecast for ${item.name} on ${dateStr}:`, error);
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Process forecasts for this day
|
||||
const flatDayForecasts = dayForecasts.flat();
|
||||
flatDayForecasts.forEach((forecast) => {
|
||||
const key = forecast.product.toLowerCase();
|
||||
if (key.includes('croissant')) dayData.croissants = forecast.predicted;
|
||||
else if (key.includes('pan')) dayData.pan = forecast.predicted;
|
||||
else if (key.includes('cafe')) dayData.cafe = forecast.predicted;
|
||||
});
|
||||
|
||||
chartData.push(dayData);
|
||||
|
||||
// Store forecasts for selected date display
|
||||
if (dateStr === selectedDate) {
|
||||
setForecastData(flatDayForecasts);
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart with 7 days of data
|
||||
setSampleForecastData(chartData);
|
||||
|
||||
// Set a sample weather alert
|
||||
setWeatherAlert({
|
||||
type: 'rain',
|
||||
impact: 'Condiciones climáticas estables para el día seleccionado',
|
||||
recommendation: 'Mantener la producción según las predicciones'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating forecasts:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load existing forecasts when component mounts or date changes
|
||||
useEffect(() => {
|
||||
const loadExistingForecasts = async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
// Try to get existing forecasts first
|
||||
const existingForecasts = await getForecasts(tenantId);
|
||||
|
||||
console.log('🔍 ForecastPage - existingForecasts:', existingForecasts);
|
||||
console.log('🔍 ForecastPage - existingForecasts type:', typeof existingForecasts);
|
||||
console.log('🔍 ForecastPage - existingForecasts isArray:', Array.isArray(existingForecasts));
|
||||
|
||||
if (Array.isArray(existingForecasts) && existingForecasts.length > 0) {
|
||||
// Filter forecasts for selected date
|
||||
const dateForecasts = existingForecasts
|
||||
.filter(f => f.forecast_date && f.forecast_date.split('T')[0] === selectedDate)
|
||||
.map(transformForecastResponse);
|
||||
|
||||
if (dateForecasts.length > 0) {
|
||||
setForecastData(dateForecasts);
|
||||
}
|
||||
|
||||
// Update 7-day chart with existing forecasts
|
||||
const chartData = [];
|
||||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||
const forecastDate = new Date();
|
||||
forecastDate.setDate(forecastDate.getDate() + dayOffset);
|
||||
const dateStr = forecastDate.toISOString().split('T')[0];
|
||||
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
croissants: 0,
|
||||
pan: 0,
|
||||
cafe: 0
|
||||
};
|
||||
|
||||
// Find existing forecasts for this day
|
||||
const dayForecasts = existingForecasts
|
||||
.filter(f => f.forecast_date && f.forecast_date.split('T')[0] === dateStr)
|
||||
.map(transformForecastResponse);
|
||||
|
||||
dayForecasts.forEach((forecast) => {
|
||||
const key = forecast.product.toLowerCase();
|
||||
if (key.includes('croissant')) dayData.croissants = forecast.predicted;
|
||||
else if (key.includes('pan')) dayData.pan = forecast.predicted;
|
||||
else if (key.includes('cafe')) dayData.cafe = forecast.predicted;
|
||||
});
|
||||
|
||||
chartData.push(dayData);
|
||||
}
|
||||
|
||||
setSampleForecastData(chartData);
|
||||
} else {
|
||||
console.log('🔍 ForecastPage - No existing forecasts found or invalid format');
|
||||
}
|
||||
|
||||
// Load alerts
|
||||
const alerts = await getForecastAlerts(tenantId);
|
||||
if (Array.isArray(alerts) && alerts.length > 0) {
|
||||
// Convert first alert to weather alert format
|
||||
const alert = alerts[0];
|
||||
setWeatherAlert({
|
||||
type: 'rain', // Default type
|
||||
impact: alert.message || 'Alert information not available',
|
||||
recommendation: 'Revisa las recomendaciones del sistema'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading existing forecasts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (inventoryItems && inventoryItems.length > 0) {
|
||||
loadExistingForecasts();
|
||||
}
|
||||
}, [tenantId, selectedDate, inventoryItems, getForecasts, getForecastAlerts]);
|
||||
|
||||
const getConfidenceColor = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'bg-success-100 text-success-800 border-success-200';
|
||||
case 'medium':
|
||||
return 'bg-warning-100 text-warning-800 border-warning-200';
|
||||
case 'low':
|
||||
return 'bg-danger-100 text-danger-800 border-danger-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceLabel = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'Alta confianza';
|
||||
case 'medium':
|
||||
return 'Confianza media';
|
||||
case 'low':
|
||||
return 'Baja confianza';
|
||||
default:
|
||||
return 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredForecasts = selectedProduct === 'all'
|
||||
? forecastData
|
||||
: forecastData.filter(f => f.product.toLowerCase().includes(selectedProduct.toLowerCase()));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
<div className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
</div>
|
||||
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Predicciones IA</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Predicciones inteligentes para optimizar tu producción
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Alert */}
|
||||
{weatherAlert && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-900">Alerta Meteorológica</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">{weatherAlert.impact}</p>
|
||||
<p className="text-blue-700 text-sm mt-2 font-medium">
|
||||
💡 Recomendación: {weatherAlert.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fecha de predicción
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filtrar por producto
|
||||
</label>
|
||||
<select
|
||||
value={selectedProduct}
|
||||
onChange={(e) => setSelectedProduct(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="all">Todos los productos</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.name.toLowerCase()}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Generar predicciones
|
||||
</label>
|
||||
<button
|
||||
onClick={generateForecasts}
|
||||
disabled={isGenerating || !tenantId || !(inventoryItems && inventoryItems.length > 0)}
|
||||
className="w-full px-4 py-3 bg-primary-500 hover:bg-primary-600 disabled:bg-gray-300 text-white rounded-xl transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Generando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingUp className="h-4 w-4 mr-2" />
|
||||
Generar Predicciones
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{forecastError && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 mr-2" />
|
||||
<span className="text-red-800 text-sm">Error: {forecastError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inventoryItems.length === 0 && !isLoading && !isGenerating && (
|
||||
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
|
||||
<span className="text-yellow-800 text-sm">
|
||||
<strong>No hay productos disponibles para predicciones.</strong>
|
||||
<br />Para usar esta funcionalidad, necesitas crear productos terminados (como pan, croissants, etc.) en tu inventario con tipo "Producto terminado".
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inventoryItems.length > 0 && forecastData.length === 0 && !isLoading && !isGenerating && (
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Info className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<span className="text-blue-800 text-sm">
|
||||
No hay predicciones para la fecha seleccionada. Haz clic en "Generar Predicciones" para crear nuevas predicciones.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forecast Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredForecasts.map((forecast, index) => (
|
||||
<div key={index} className="bg-white p-6 rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{forecast.product}</h3>
|
||||
<span className={`px-3 py-1 rounded-lg text-xs font-medium border ${getConfidenceColor(forecast.confidence)}`}>
|
||||
{getConfidenceLabel(forecast.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-3xl font-bold text-gray-900">{forecast.predicted}</span>
|
||||
<span className="text-gray-500 ml-2">unidades</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Predicción para {new Date(forecast.date).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||
<Info className="h-4 w-4 mr-1" />
|
||||
Factores considerados
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{forecast.factors.map((factor, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-center">
|
||||
<span className="w-1.5 h-1.5 bg-primary-500 rounded-full mr-2"></span>
|
||||
{factor}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{forecast.weatherImpact && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center">
|
||||
<Cloud className="h-4 w-4 mr-1" />
|
||||
Impacto del clima
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600">{forecast.weatherImpact}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trend Chart */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Tendencia de Predicciones (Próximos 7 Días)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={sampleForecastData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
labelFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="croissants"
|
||||
stroke="#f97316"
|
||||
strokeWidth={3}
|
||||
name="Croissants"
|
||||
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pan"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={3}
|
||||
name="Pan de molde"
|
||||
dot={{ fill: '#22c55e', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cafe"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
name="Café"
|
||||
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Recomendaciones Inteligentes
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start p-4 bg-success-50 rounded-lg border border-success-200">
|
||||
<TrendingUp className="h-5 w-5 text-success-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-success-900">Oportunidad de Aumento</h4>
|
||||
<p className="text-success-800 text-sm mt-1">
|
||||
La demanda de café aumentará un 15% esta semana por las bajas temperaturas.
|
||||
Considera aumentar el stock de café y bebidas calientes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start p-4 bg-warning-50 rounded-lg border border-warning-200">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-warning-900">Ajuste Recomendado</h4>
|
||||
<p className="text-warning-800 text-sm mt-1">
|
||||
Las napolitanas muestran alta variabilidad. Considera reducir la producción
|
||||
inicial y hornear más según demanda en tiempo real.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<Info className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Optimización de Horarios</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">
|
||||
El pico de demanda de croissants será entre 7:30-9:00 AM.
|
||||
Asegúrate de tener suficiente stock listo para esas horas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => tenantId && exportForecasts(tenantId, 'csv')}
|
||||
disabled={!tenantId || forecastData.length === 0}
|
||||
className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">Exportar Predicciones</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Descargar en formato CSV</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={generateForecasts}
|
||||
disabled={isGenerating || !tenantId || !(inventoryItems && inventoryItems.length > 0)}
|
||||
className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">Actualizar Predicciones</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Generar nuevas predicciones</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => tenantId && getForecastAlerts(tenantId)}
|
||||
disabled={!tenantId}
|
||||
className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">Ver Alertas</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Revisar notificaciones del sistema</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastPage;
|
||||
7
frontend/src/pages/index.ts
Normal file
7
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Public pages
|
||||
export * from './public';
|
||||
|
||||
// App pages
|
||||
export { default as DashboardPage } from './app/DashboardPage';
|
||||
export * from './app/operations';
|
||||
export * from './app/analytics';
|
||||
@@ -1,514 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
Download,
|
||||
Upload,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Package,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Loader,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import {
|
||||
InventorySearchParams,
|
||||
ProductType,
|
||||
CreateInventoryItemRequest,
|
||||
UpdateInventoryItemRequest,
|
||||
StockAdjustmentRequest,
|
||||
InventoryItem
|
||||
} from '../../api/services/inventory.service';
|
||||
|
||||
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
interface FilterState {
|
||||
search: string;
|
||||
product_type?: ProductType;
|
||||
category?: string;
|
||||
is_active?: boolean;
|
||||
low_stock_only?: boolean;
|
||||
expiring_soon_only?: boolean;
|
||||
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface InventoryPageProps {
|
||||
view?: string;
|
||||
}
|
||||
|
||||
const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' }) => {
|
||||
const {
|
||||
items,
|
||||
stockLevels,
|
||||
dashboardData,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
loadItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
adjustStock,
|
||||
refresh,
|
||||
clearError
|
||||
} = useInventory();
|
||||
|
||||
// Local state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: '',
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load items when filters change
|
||||
useEffect(() => {
|
||||
const searchParams: InventorySearchParams = {
|
||||
...filters,
|
||||
page: 1,
|
||||
limit: 20
|
||||
};
|
||||
|
||||
// Remove empty values
|
||||
Object.keys(searchParams).forEach(key => {
|
||||
if (searchParams[key as keyof InventorySearchParams] === '' ||
|
||||
searchParams[key as keyof InventorySearchParams] === undefined) {
|
||||
delete searchParams[key as keyof InventorySearchParams];
|
||||
}
|
||||
});
|
||||
|
||||
loadItems(searchParams);
|
||||
}, [filters, loadItems]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setFilters(prev => ({ ...prev, search: value }));
|
||||
}, []);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({
|
||||
search: '',
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle item selection
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(itemId)) {
|
||||
newSelection.delete(itemId);
|
||||
} else {
|
||||
newSelection.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
// Handle stock adjustment
|
||||
const handleStockAdjust = async (item: InventoryItem, adjustment: StockAdjustmentRequest) => {
|
||||
const result = await adjustStock(item.id, adjustment);
|
||||
if (result) {
|
||||
// Refresh data to get updated stock levels
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle item edit
|
||||
const handleItemEdit = (item: InventoryItem) => {
|
||||
// TODO: Open edit modal
|
||||
console.log('Edit item:', item);
|
||||
};
|
||||
|
||||
// Handle item view details
|
||||
const handleItemViewDetails = (item: InventoryItem) => {
|
||||
// TODO: Open details modal or navigate to details page
|
||||
console.log('View details:', item);
|
||||
};
|
||||
|
||||
// Handle view item by ID (for alerts)
|
||||
const handleViewItemById = (itemId: string) => {
|
||||
const item = items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
handleItemViewDetails(item);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Get quick stats
|
||||
const getQuickStats = () => {
|
||||
const totalItems = items.length;
|
||||
const lowStockItems = 0;
|
||||
const expiringItems = 0;
|
||||
const totalValue = dashboardData?.total_value || 0;
|
||||
|
||||
return { totalItems, lowStockItems, expiringItems, totalValue };
|
||||
};
|
||||
|
||||
const stats = getQuickStats();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Inventario</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Administra tus productos, stock y alertas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
disabled={isLoading}
|
||||
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nuevo Producto</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-4">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<Package className="w-8 h-8 text-blue-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Total Productos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<TrendingDown className="w-8 h-8 text-yellow-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Stock Bajo</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.lowStockItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-8 h-8 text-red-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Por Vencer</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.expiringItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="w-8 h-8 text-green-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Valor Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
€{stats.totalValue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-lg border mb-6 p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar productos..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
|
||||
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Filtros</span>
|
||||
</button>
|
||||
|
||||
<div className="flex rounded-lg border">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{/* Product Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Producto
|
||||
</label>
|
||||
<select
|
||||
value={filters.product_type || ''}
|
||||
onChange={(e) => handleFilterChange('product_type', e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="ingredient">Ingredientes</option>
|
||||
<option value="finished_product">Productos Finales</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
value={filters.is_active?.toString() || ''}
|
||||
onChange={(e) => handleFilterChange('is_active',
|
||||
e.target.value === '' ? undefined : e.target.value === 'true'
|
||||
)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="true">Activos</option>
|
||||
<option value="false">Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stock Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stock
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.low_stock_only || false}
|
||||
onChange={(e) => handleFilterChange('low_stock_only', e.target.checked || undefined)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Stock bajo</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.expiring_soon_only || false}
|
||||
onChange={(e) => handleFilterChange('expiring_soon_only', e.target.checked || undefined)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Por vencer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ordenar por
|
||||
</label>
|
||||
<select
|
||||
value={filters.sort_by || 'name'}
|
||||
onChange={(e) => handleFilterChange('sort_by', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="name">Nombre</option>
|
||||
<option value="category">Categoría</option>
|
||||
<option value="stock_level">Nivel de Stock</option>
|
||||
<option value="created_at">Fecha de Creación</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Orden
|
||||
</label>
|
||||
<select
|
||||
value={filters.sort_order || 'asc'}
|
||||
onChange={(e) => handleFilterChange('sort_order', e.target.value as 'asc' | 'desc')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="asc">Ascendente</option>
|
||||
<option value="desc">Descendente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items Grid/List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Cargando inventario...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-red-900 mb-2">Error al cargar inventario</h3>
|
||||
<p className="text-red-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{filters.search || Object.values(filters).some(v => v)
|
||||
? 'No se encontraron productos'
|
||||
: 'No tienes productos en tu inventario'
|
||||
}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{filters.search || Object.values(filters).some(v => v)
|
||||
? 'Prueba ajustando los filtros de búsqueda'
|
||||
: 'Comienza agregando tu primer producto al inventario'
|
||||
}
|
||||
</p>
|
||||
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Agregar Producto
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{items.map((item) => (
|
||||
<InventoryItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
stockLevel={stockLevels[item.id]}
|
||||
compact={viewMode === 'list'}
|
||||
onEdit={handleItemEdit}
|
||||
onViewDetails={handleItemViewDetails}
|
||||
onStockAdjust={handleStockAdjust}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
|
||||
{pagination.total} productos
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => {
|
||||
const searchParams: InventorySearchParams = {
|
||||
...filters,
|
||||
page,
|
||||
limit: pagination.limit
|
||||
};
|
||||
loadItems(searchParams);
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg ${
|
||||
page === pagination.page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
@@ -1,547 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Package,
|
||||
Clock,
|
||||
Users,
|
||||
Star,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Play,
|
||||
ArrowRight,
|
||||
MapPin,
|
||||
Quote
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LandingPageProps {
|
||||
// No props needed with React Router
|
||||
}
|
||||
|
||||
const LandingPage: React.FC<LandingPageProps> = () => {
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
const [currentTestimonial, setCurrentTestimonial] = useState(0);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Predicciones Precisas",
|
||||
description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión",
|
||||
color: "bg-success-100 text-success-600"
|
||||
},
|
||||
{
|
||||
icon: TrendingDown,
|
||||
title: "Reduce Desperdicios",
|
||||
description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción",
|
||||
color: "bg-primary-100 text-primary-600"
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Ahorra Tiempo",
|
||||
description: "30-45 minutos menos al día en planificación manual de producción",
|
||||
color: "bg-blue-100 text-blue-600"
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: "Gestión Inteligente",
|
||||
description: "Pedidos automáticos y alertas de stock basados en predicciones",
|
||||
color: "bg-purple-100 text-purple-600"
|
||||
}
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: "María González",
|
||||
business: "Panadería San Miguel",
|
||||
location: "Chamberí, Madrid",
|
||||
text: "Con PanIA reduje mis desperdicios un 20% en el primer mes. La IA realmente entiende mi negocio.",
|
||||
rating: 5,
|
||||
savings: "€280/mes"
|
||||
},
|
||||
{
|
||||
name: "Carlos Ruiz",
|
||||
business: "Obrador Central Goya",
|
||||
location: "Salamanca, Madrid",
|
||||
text: "Gestiono 4 puntos de venta y PanIA me ahorra 2 horas diarias de planificación. Imprescindible.",
|
||||
rating: 5,
|
||||
savings: "€450/mes"
|
||||
},
|
||||
{
|
||||
name: "Ana Martín",
|
||||
business: "Café & Pan Malasaña",
|
||||
location: "Malasaña, Madrid",
|
||||
text: "Las predicciones son increíblemente precisas. Ya no me quedo sin croissants en el desayuno.",
|
||||
rating: 5,
|
||||
savings: "€190/mes"
|
||||
}
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ number: "500+", label: "Panaderías en Madrid" },
|
||||
{ number: "87%", label: "Precisión en predicciones" },
|
||||
{ number: "25%", label: "Reducción desperdicios" },
|
||||
{ number: "€350", label: "Ahorro mensual promedio" }
|
||||
];
|
||||
|
||||
const madridDistricts = [
|
||||
"Centro", "Salamanca", "Chamberí", "Retiro", "Arganzuela",
|
||||
"Moncloa", "Chamartín", "Hortaleza", "Fuencarral", "Tetuán"
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTestimonial((prev) => (prev + 1) % testimonials.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [testimonials.length]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 bg-primary-500 rounded-xl flex items-center justify-center mr-3">
|
||||
<span className="text-white text-xl font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">PanIA</span>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex space-x-8">
|
||||
<a href="#features" className="text-gray-600 hover:text-primary-600 transition-colors">Características</a>
|
||||
<a href="#testimonials" className="text-gray-600 hover:text-primary-600 transition-colors">Testimonios</a>
|
||||
<a href="#pricing" className="text-gray-600 hover:text-primary-600 transition-colors">Precios</a>
|
||||
<a href="#contact" className="text-gray-600 hover:text-primary-600 transition-colors">Contacto</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
Prueba gratis
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-primary-50 to-orange-100 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
IA líder para panaderías en Madrid
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
La primera IA para
|
||||
<span className="text-primary-600 block">tu panadería</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
|
||||
Transforma tus datos de ventas en predicciones precisas.
|
||||
Reduce desperdicios, maximiza ganancias y optimiza tu producción
|
||||
con inteligencia artificial diseñada para panaderías madrileñas.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-lg transform hover:-translate-y-1 flex items-center justify-center"
|
||||
>
|
||||
Comenzar gratis
|
||||
<ArrowRight className="h-5 w-5 ml-2" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsVideoModalOpen(true)}
|
||||
className="border-2 border-gray-300 text-gray-700 px-8 py-4 rounded-xl font-semibold text-lg hover:border-primary-500 hover:text-primary-600 transition-all flex items-center justify-center"
|
||||
>
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
Ver demo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||
<span>30 días gratis • Sin tarjeta de crédito • Configuración en 5 minutos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 border">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Predicciones para Hoy</h3>
|
||||
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||
87% precisión
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ product: "Croissants", predicted: 48, confidence: "high", change: 8 },
|
||||
{ product: "Pan de molde", predicted: 35, confidence: "high", change: 3 },
|
||||
{ product: "Café", predicted: 72, confidence: "medium", change: -5 }
|
||||
].map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.product}</div>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
{item.change > 0 ? (
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 text-red-500 mr-1" />
|
||||
)}
|
||||
{Math.abs(item.change)} vs ayer
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">{item.predicted}</div>
|
||||
<div className={`text-xs px-2 py-1 rounded ${
|
||||
item.confidence === 'high'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{item.confidence === 'high' ? 'Alta confianza' : 'Media confianza'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating stats */}
|
||||
<div className="absolute -top-6 -right-6 bg-white rounded-xl shadow-lg p-4 border">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">-25%</div>
|
||||
<div className="text-sm text-gray-600">Desperdicios</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-6 -left-6 bg-white rounded-xl shadow-lg p-4 border">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary-600">€350</div>
|
||||
<div className="text-sm text-gray-600">Ahorro/mes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Resultados que hablan por sí solos
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Más de 500 panaderías en Madrid ya confían en PanIA para optimizar su producción
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-4xl lg:text-5xl font-bold text-primary-600 mb-2">
|
||||
{stat.number}
|
||||
</div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
IA diseñada para panaderías madrileñas
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Cada característica está pensada para resolver los desafíos específicos
|
||||
de las panaderías en Madrid: desde el clima hasta los patrones de consumo locales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-16">
|
||||
{features.map((feature, index) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl shadow-soft hover:shadow-medium transition-all">
|
||||
<div className={`w-12 h-12 ${feature.color} rounded-xl flex items-center justify-center mb-6`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Madrid-specific features */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-soft">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Especializado en Madrid
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
PanIA conoce Madrid como ninguna otra IA. Integra datos del clima,
|
||||
tráfico, eventos y patrones de consumo específicos de la capital.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Integración con datos meteorológicos de AEMET</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Análisis de eventos y festividades locales</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Patrones de tráfico peatonal por distrito</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Horarios de siesta y patrones españoles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-primary-50 p-6 rounded-xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<MapPin className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h4 className="font-semibold text-gray-900">Distritos cubiertos</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{madridDistricts.map((district, index) => (
|
||||
<div key={index} className="text-sm text-gray-700 bg-white px-3 py-1 rounded-lg">
|
||||
{district}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section id="testimonials" className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Lo que dicen nuestros clientes
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
Panaderías reales, resultados reales en Madrid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="bg-gradient-to-br from-primary-50 to-orange-50 rounded-2xl p-8 lg:p-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center">
|
||||
<Quote className="h-12 w-12 text-primary-400 mx-auto mb-6" />
|
||||
|
||||
<blockquote className="text-2xl lg:text-3xl font-medium text-gray-900 mb-8 leading-relaxed">
|
||||
"{testimonials[currentTestimonial].text}"
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
{[...Array(testimonials[currentTestimonial].rating)].map((_, i) => (
|
||||
<Star key={i} className="h-5 w-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-gray-900 text-lg">
|
||||
{testimonials[currentTestimonial].name}
|
||||
</div>
|
||||
<div className="text-primary-600 font-medium">
|
||||
{testimonials[currentTestimonial].business}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm">
|
||||
{testimonials[currentTestimonial].location}
|
||||
</div>
|
||||
<div className="inline-flex items-center bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-medium mt-2">
|
||||
Ahorro: {testimonials[currentTestimonial].savings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial indicators */}
|
||||
<div className="flex justify-center mt-8 space-x-2">
|
||||
{testimonials.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentTestimonial(index)}
|
||||
className={`w-3 h-3 rounded-full transition-all ${
|
||||
index === currentTestimonial ? 'bg-primary-500' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gradient-to-br from-primary-600 to-orange-600">
|
||||
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
¿Listo para transformar tu panadería?
|
||||
</h2>
|
||||
<p className="text-xl text-primary-100 mb-8 leading-relaxed">
|
||||
Únete a más de 500 panaderías en Madrid que ya reducen desperdicios
|
||||
y maximizan ganancias con PanIA
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-lg transform hover:-translate-y-1"
|
||||
>
|
||||
Comenzar prueba gratuita
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="border-2 border-white text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-primary-600 transition-all"
|
||||
>
|
||||
Ya tengo cuenta
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 text-primary-100">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
<span>30 días gratis</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
<span>Sin tarjeta de crédito</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
<span>Soporte en español</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-lg font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">PanIA</span>
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Inteligencia Artificial para panaderías en Madrid
|
||||
</p>
|
||||
<div className="text-gray-400 text-sm">
|
||||
<p>📍 Madrid, España</p>
|
||||
<p>📧 hola@pania.es</p>
|
||||
<p>📞 +34 900 123 456</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Producto</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#features" className="hover:text-white transition-colors">Características</a></li>
|
||||
<li><a href="#pricing" className="hover:text-white transition-colors">Precios</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Demo</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">API</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Soporte</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">Centro de ayuda</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Documentación</a></li>
|
||||
<li><a href="#contact" className="hover:text-white transition-colors">Contacto</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Estado del sistema</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Legal</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">Privacidad</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Términos</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Cookies</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">GDPR</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
||||
<p>© 2024 PanIA. Todos los derechos reservados. Hecho con ❤️ en Madrid.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Video Modal */}
|
||||
{isVideoModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900">Demo de PanIA</h3>
|
||||
<button
|
||||
onClick={() => setIsVideoModalOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Play className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Video demo disponible próximamente</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Mientras tanto, puedes comenzar tu prueba gratuita
|
||||
</p>
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => setIsVideoModalOpen(false)}
|
||||
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors inline-block"
|
||||
>
|
||||
Comenzar prueba gratis
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -1,168 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, TrendingUp, Clock, DollarSign, BarChart3 } from 'lucide-react';
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-orange-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">PanIA</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
Prueba gratis
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
⭐ IA líder para panaderías en Madrid
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
La primera IA para
|
||||
<span className="text-primary-600 block">tu panadería</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-8 leading-relaxed max-w-3xl mx-auto">
|
||||
Transforma tus datos de ventas en predicciones precisas.
|
||||
Reduce desperdicios, maximiza ganancias y optimiza tu producción
|
||||
con inteligencia artificial diseñada para panaderías madrileñas.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-16">
|
||||
<Link
|
||||
to="/register"
|
||||
className="w-full sm:w-auto bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-xl hover:-translate-y-1 flex items-center justify-center group"
|
||||
>
|
||||
Empezar Gratis
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="w-full sm:w-auto border-2 border-primary-500 text-primary-500 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-50 transition-all"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Todo lo que necesitas para optimizar tu panadería
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
Tecnología de vanguardia diseñada específicamente para panaderías
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Predicciones Precisas",
|
||||
description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión",
|
||||
color: "bg-green-100 text-green-600"
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Reduce Desperdicios",
|
||||
description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción",
|
||||
color: "bg-blue-100 text-blue-600"
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
title: "Ahorra Dinero",
|
||||
description: "Ahorra hasta €500/mes reduciendo costos operativos y desperdicios",
|
||||
color: "bg-purple-100 text-purple-600"
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Analytics Avanzados",
|
||||
description: "Reportes detallados y insights que te ayudan a tomar mejores decisiones",
|
||||
color: "bg-orange-100 text-orange-600"
|
||||
}
|
||||
].map((feature, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className={`inline-flex h-16 w-16 items-center justify-center rounded-xl ${feature.color} mb-6`}>
|
||||
<feature.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-primary-500 py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
¿Listo para revolucionar tu panadería?
|
||||
</h2>
|
||||
<p className="text-xl text-primary-100 mb-8 max-w-2xl mx-auto">
|
||||
Únete a más de 500 panaderías en Madrid que ya confían en PanIA para optimizar su negocio
|
||||
</p>
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex items-center bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
>
|
||||
Empezar Prueba Gratuita
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">PanIA</span>
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Inteligencia Artificial para panaderías madrileñas
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
© 2024 PanIA. Todos los derechos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,620 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock, BarChart3, TrendingUp, Euro, Settings } from 'lucide-react';
|
||||
|
||||
// Import complex components
|
||||
import WhatIfPlanner from '../../components/ui/WhatIfPlanner';
|
||||
import DemandHeatmap from '../../components/ui/DemandHeatmap';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
supplier: string;
|
||||
items: OrderItem[];
|
||||
orderDate: string;
|
||||
deliveryDate: string;
|
||||
status: 'pending' | 'confirmed' | 'delivered' | 'cancelled';
|
||||
total: number;
|
||||
type: 'ingredients' | 'consumables';
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
price: number;
|
||||
suggested?: boolean;
|
||||
}
|
||||
|
||||
interface OrdersPageProps {
|
||||
view?: string;
|
||||
}
|
||||
|
||||
const OrdersPage: React.FC<OrdersPageProps> = ({ view = 'incoming' }) => {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showNewOrder, setShowNewOrder] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'orders' | 'analytics' | 'forecasting' | 'suppliers'>('orders');
|
||||
|
||||
// Sample orders data
|
||||
const sampleOrders: Order[] = [
|
||||
{
|
||||
id: '1',
|
||||
supplier: 'Harinas Castellana',
|
||||
items: [
|
||||
{ name: 'Harina de trigo', quantity: 50, unit: 'kg', price: 0.85, suggested: true },
|
||||
{ name: 'Levadura fresca', quantity: 2, unit: 'kg', price: 3.20 },
|
||||
{ name: 'Sal marina', quantity: 5, unit: 'kg', price: 1.10 }
|
||||
],
|
||||
orderDate: '2024-11-03',
|
||||
deliveryDate: '2024-11-05',
|
||||
status: 'pending',
|
||||
total: 52.50,
|
||||
type: 'ingredients'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
supplier: 'Distribuciones Madrid',
|
||||
items: [
|
||||
{ name: 'Vasos de café 250ml', quantity: 1000, unit: 'unidades', price: 0.08 },
|
||||
{ name: 'Bolsas papel kraft', quantity: 500, unit: 'unidades', price: 0.12, suggested: true },
|
||||
{ name: 'Servilletas', quantity: 20, unit: 'paquetes', price: 2.50 }
|
||||
],
|
||||
orderDate: '2024-11-02',
|
||||
deliveryDate: '2024-11-04',
|
||||
status: 'confirmed',
|
||||
total: 190.00,
|
||||
type: 'consumables'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
supplier: 'Lácteos Frescos SA',
|
||||
items: [
|
||||
{ name: 'Leche entera', quantity: 20, unit: 'litros', price: 0.95 },
|
||||
{ name: 'Mantequilla', quantity: 5, unit: 'kg', price: 4.20 },
|
||||
{ name: 'Nata para montar', quantity: 3, unit: 'litros', price: 2.80 }
|
||||
],
|
||||
orderDate: '2024-11-01',
|
||||
deliveryDate: '2024-11-03',
|
||||
status: 'delivered',
|
||||
total: 47.40,
|
||||
type: 'ingredients'
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrders = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setOrders(sampleOrders);
|
||||
} catch (error) {
|
||||
console.error('Error loading orders:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrders();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-warning-100 text-warning-800 border-warning-200';
|
||||
case 'confirmed':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'delivered':
|
||||
return 'bg-success-100 text-success-800 border-success-200';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Pendiente';
|
||||
case 'confirmed':
|
||||
return 'Confirmado';
|
||||
case 'delivered':
|
||||
return 'Entregado';
|
||||
case 'cancelled':
|
||||
return 'Cancelado';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4" />;
|
||||
case 'confirmed':
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
case 'delivered':
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
case 'cancelled':
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Sample data for complex components
|
||||
const orderDemandHeatmapData = [
|
||||
{
|
||||
weekStart: '2024-11-04',
|
||||
days: [
|
||||
{
|
||||
date: '2024-11-04',
|
||||
demand: 180,
|
||||
isToday: true,
|
||||
products: [
|
||||
{ name: 'Harina de trigo', demand: 50, confidence: 'high' as const },
|
||||
{ name: 'Levadura fresca', demand: 2, confidence: 'high' as const },
|
||||
{ name: 'Mantequilla', demand: 5, confidence: 'medium' as const },
|
||||
{ name: 'Vasos café', demand: 1000, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{ date: '2024-11-05', demand: 165, isForecast: true },
|
||||
{ date: '2024-11-06', demand: 195, isForecast: true },
|
||||
{ date: '2024-11-07', demand: 220, isForecast: true },
|
||||
{ date: '2024-11-08', demand: 185, isForecast: true },
|
||||
{ date: '2024-11-09', demand: 250, isForecast: true },
|
||||
{ date: '2024-11-10', demand: 160, isForecast: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const baselineSupplyData = {
|
||||
totalDemand: 180,
|
||||
totalRevenue: 420,
|
||||
products: [
|
||||
{ name: 'Harina de trigo', demand: 50, price: 0.85 },
|
||||
{ name: 'Levadura fresca', demand: 2, price: 3.20 },
|
||||
{ name: 'Mantequilla', demand: 5, price: 4.20 },
|
||||
{ name: 'Leche entera', demand: 20, price: 0.95 },
|
||||
{ name: 'Vasos café', demand: 1000, price: 0.08 },
|
||||
]
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter(order => {
|
||||
if (activeTab === 'orders') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleDeleteOrder = (orderId: string) => {
|
||||
setOrders(prev => prev.filter(order => order.id !== orderId));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-48 bg-gray-200 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Pedidos</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Administra tus pedidos de ingredientes y consumibles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowNewOrder(true)}
|
||||
className="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Nuevo Pedido
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-soft p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'orders', label: 'Gestión de Pedidos', icon: Package, count: orders.length },
|
||||
{ id: 'analytics', label: 'Análisis', icon: BarChart3 },
|
||||
{ id: 'forecasting', label: 'Simulaciones', icon: TrendingUp },
|
||||
{ id: 'suppliers', label: 'Proveedores', icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium rounded-lg transition-all flex items-center justify-center ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.label}
|
||||
{tab.count && (
|
||||
<span className="ml-2 px-2 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Suggestions */}
|
||||
<div className="bg-gradient-to-r from-primary-50 to-orange-50 border border-primary-200 rounded-xl p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="p-2 bg-primary-100 rounded-lg mr-4">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-primary-900 mb-2">Sugerencias Inteligentes de Pedidos</h3>
|
||||
<div className="space-y-2 text-sm text-primary-800">
|
||||
<p>• <strong>Harina de trigo:</strong> Stock bajo detectado. Recomendamos pedir 50kg para cubrir 2 semanas.</p>
|
||||
<p>• <strong>Bolsas de papel:</strong> Aumento del 15% en takeaway. Considera aumentar el pedido habitual.</p>
|
||||
<p>• <strong>Café en grano:</strong> Predicción de alta demanda por temperaturas bajas. +20% recomendado.</p>
|
||||
</div>
|
||||
<button className="mt-3 text-primary-700 hover:text-primary-600 font-medium text-sm">
|
||||
Ver todas las sugerencias →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'orders' && (
|
||||
<>
|
||||
{/* Orders Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredOrders.map((order) => (
|
||||
<div key={order.id} className="bg-white rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||
{/* Order Header */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{order.supplier}</h3>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium border flex items-center ${getStatusColor(order.status)}`}>
|
||||
{getStatusIcon(order.status)}
|
||||
<span className="ml-1">{getStatusLabel(order.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>Entrega: {new Date(order.deliveryDate).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
order.type === 'ingredients'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{order.type === 'ingredients' ? 'Ingredientes' : 'Consumibles'}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-gray-900">€{order.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="p-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Artículos ({order.items.length})</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{order.items.map((item, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<div className="flex-1">
|
||||
<span className="text-gray-900">{item.name}</span>
|
||||
{item.suggested && (
|
||||
<span className="ml-2 text-xs bg-primary-100 text-primary-700 px-1 py-0.5 rounded">
|
||||
IA
|
||||
</span>
|
||||
)}
|
||||
<div className="text-gray-500 text-xs">
|
||||
{item.quantity} {item.unit} × €{item.price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-900 font-medium">
|
||||
€{(item.quantity * item.price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Actions */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex space-x-2">
|
||||
<button className="flex-1 py-2 px-3 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteOrder(order.id)}
|
||||
className="py-2 px-3 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredOrders.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay pedidos</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{activeTab === 'orders'
|
||||
? 'Aún no has creado ningún pedido'
|
||||
: 'No hay datos disponibles para esta sección'
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewOrder(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Crear primer pedido
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{orders.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Pendientes</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Gasto Mensual</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
€{orders.reduce((sum, order) => sum + order.total, 0).toFixed(0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Pedido Automático</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Basado en predicciones IA</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Gestión de Proveedores</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Añadir o editar proveedores</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Historial de Gastos</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Ver análisis de costos</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Stock bajo y vencimientos</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<DemandHeatmap
|
||||
data={orderDemandHeatmapData}
|
||||
selectedProduct="Ingredientes"
|
||||
onDateClick={(date) => {
|
||||
console.log('Selected date:', date);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cost Analysis Chart */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Euro className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Análisis de Costos
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-green-800 font-semibold">Ahorro Mensual</div>
|
||||
<div className="text-2xl font-bold text-green-900">€124.50</div>
|
||||
<div className="text-sm text-green-700">vs mes anterior</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-blue-800 font-semibold">Gasto Promedio</div>
|
||||
<div className="text-2xl font-bold text-blue-900">€289.95</div>
|
||||
<div className="text-sm text-blue-700">por pedido</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="text-purple-800 font-semibold">Eficiencia</div>
|
||||
<div className="text-2xl font-bold text-purple-900">94.2%</div>
|
||||
<div className="text-sm text-purple-700">predicción IA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>Gráfico de tendencias de costos</p>
|
||||
<p className="text-sm">Próximamente disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecasting/Simulations Tab */}
|
||||
{activeTab === 'forecasting' && (
|
||||
<div className="space-y-6">
|
||||
<WhatIfPlanner
|
||||
baselineData={baselineSupplyData}
|
||||
onScenarioRun={(scenario, result) => {
|
||||
console.log('Scenario run:', scenario, result);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suppliers Tab */}
|
||||
{activeTab === 'suppliers' && (
|
||||
<div className="space-y-6">
|
||||
{/* Suppliers Management */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Gestión de Proveedores
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
name: 'Harinas Castellana',
|
||||
category: 'Ingredientes',
|
||||
rating: 4.8,
|
||||
reliability: 98,
|
||||
nextDelivery: '2024-11-05',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Distribuciones Madrid',
|
||||
category: 'Consumibles',
|
||||
rating: 4.5,
|
||||
reliability: 95,
|
||||
nextDelivery: '2024-11-04',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Lácteos Frescos SA',
|
||||
category: 'Ingredientes',
|
||||
rating: 4.9,
|
||||
reliability: 99,
|
||||
nextDelivery: '2024-11-03',
|
||||
status: 'active'
|
||||
}
|
||||
].map((supplier, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900">{supplier.name}</h4>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
{supplier.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Categoría:</span> {supplier.category}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Calificación:</span> ⭐ {supplier.rating}/5
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Confiabilidad:</span> {supplier.reliability}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Próxima entrega:</span> {new Date(supplier.nextDelivery).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<button className="flex-1 px-3 py-2 text-sm bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
<button className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
Contactar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Añadir Proveedor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Order Modal Placeholder */}
|
||||
{showNewOrder && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nuevo Pedido</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Esta funcionalidad estará disponible próximamente. PanIA analizará tus necesidades
|
||||
y creará pedidos automáticos basados en las predicciones de demanda.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowNewOrder(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewOrder(false)}
|
||||
className="flex-1 py-2 px-4 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Entendido
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { POSManagementPage } from '../../components/pos';
|
||||
|
||||
interface POSPageProps {
|
||||
view?: 'integrations' | 'sync-status' | 'transactions';
|
||||
}
|
||||
|
||||
const POSPage: React.FC<POSPageProps> = ({ view = 'integrations' }) => {
|
||||
// For now, all views route to the main POS management page
|
||||
// In the future, you can create separate components for different views
|
||||
|
||||
switch (view) {
|
||||
case 'integrations':
|
||||
return (
|
||||
<div className="p-6">
|
||||
<POSManagementPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'sync-status':
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Future: Create dedicated sync status view */}
|
||||
<POSManagementPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'transactions':
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Future: Create dedicated transactions view */}
|
||||
<POSManagementPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-6">
|
||||
<POSManagementPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default POSPage;
|
||||
@@ -1,677 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Clock, Calendar, ChefHat, TrendingUp, AlertTriangle,
|
||||
CheckCircle, Settings, Plus, BarChart3, Users,
|
||||
Timer, Target, Activity, Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
// Import existing complex components
|
||||
import ProductionSchedule from '../../components/ui/ProductionSchedule';
|
||||
import DemandHeatmap from '../../components/ui/DemandHeatmap';
|
||||
import { useDashboard } from '../../hooks/useDashboard';
|
||||
|
||||
// Types for production management
|
||||
interface ProductionMetrics {
|
||||
efficiency: number;
|
||||
onTimeCompletion: number;
|
||||
wastePercentage: number;
|
||||
energyUsage: number;
|
||||
staffUtilization: number;
|
||||
}
|
||||
|
||||
interface ProductionBatch {
|
||||
id: string;
|
||||
product: string;
|
||||
batchSize: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
||||
assignedStaff: string[];
|
||||
actualYield: number;
|
||||
expectedYield: number;
|
||||
notes?: string;
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
}
|
||||
|
||||
interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'baker' | 'assistant' | 'decorator';
|
||||
currentTask?: string;
|
||||
status: 'available' | 'busy' | 'break';
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'cooling_rack';
|
||||
status: 'idle' | 'in_use' | 'maintenance' | 'error';
|
||||
currentBatch?: string;
|
||||
temperature?: number;
|
||||
maintenanceDue?: string;
|
||||
}
|
||||
|
||||
interface ProductionPageProps {
|
||||
view?: 'schedule' | 'batches' | 'analytics' | 'staff' | 'equipment' | 'active-batches';
|
||||
}
|
||||
|
||||
const ProductionPage: React.FC<ProductionPageProps> = ({ view = 'schedule' }) => {
|
||||
const { todayForecasts, metrics, weather, isLoading } = useDashboard();
|
||||
const [activeTab, setActiveTab] = useState<'schedule' | 'batches' | 'analytics' | 'staff' | 'equipment'>(
|
||||
view === 'active-batches' ? 'batches' : view as 'schedule' | 'batches' | 'analytics' | 'staff' | 'equipment'
|
||||
);
|
||||
const [productionMetrics, setProductionMetrics] = useState<ProductionMetrics>({
|
||||
efficiency: 87.5,
|
||||
onTimeCompletion: 94.2,
|
||||
wastePercentage: 3.8,
|
||||
energyUsage: 156.7,
|
||||
staffUtilization: 78.3
|
||||
});
|
||||
|
||||
// Sample production schedule data
|
||||
const [productionSchedule, setProductionSchedule] = useState([
|
||||
{
|
||||
time: '05:00 AM',
|
||||
items: [
|
||||
{
|
||||
id: 'prod-1',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 180,
|
||||
status: 'in_progress' as const,
|
||||
confidence: 0.92,
|
||||
notes: 'Alta demanda prevista - lote doble'
|
||||
},
|
||||
{
|
||||
id: 'prod-2',
|
||||
product: 'Pan de molde',
|
||||
quantity: 35,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 240,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.88
|
||||
}
|
||||
],
|
||||
totalTime: 420
|
||||
},
|
||||
{
|
||||
time: '08:00 AM',
|
||||
items: [
|
||||
{
|
||||
id: 'prod-3',
|
||||
product: 'Baguettes',
|
||||
quantity: 25,
|
||||
priority: 'medium' as const,
|
||||
estimatedTime: 200,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.75
|
||||
},
|
||||
{
|
||||
id: 'prod-4',
|
||||
product: 'Magdalenas',
|
||||
quantity: 60,
|
||||
priority: 'medium' as const,
|
||||
estimatedTime: 120,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.82
|
||||
}
|
||||
],
|
||||
totalTime: 320
|
||||
}
|
||||
]);
|
||||
|
||||
const [productionBatches, setProductionBatches] = useState<ProductionBatch[]>([
|
||||
{
|
||||
id: 'batch-1',
|
||||
product: 'Croissants',
|
||||
batchSize: 48,
|
||||
startTime: '05:00',
|
||||
endTime: '08:00',
|
||||
status: 'in_progress',
|
||||
assignedStaff: ['maria-lopez', 'carlos-ruiz'],
|
||||
actualYield: 45,
|
||||
expectedYield: 48,
|
||||
temperature: 180,
|
||||
humidity: 65,
|
||||
notes: 'Masa fermentando correctamente'
|
||||
},
|
||||
{
|
||||
id: 'batch-2',
|
||||
product: 'Pan de molde',
|
||||
batchSize: 35,
|
||||
startTime: '06:30',
|
||||
endTime: '10:30',
|
||||
status: 'planned',
|
||||
assignedStaff: ['ana-garcia'],
|
||||
actualYield: 0,
|
||||
expectedYield: 35,
|
||||
notes: 'Esperando finalización de croissants'
|
||||
}
|
||||
]);
|
||||
|
||||
const [staff, setStaff] = useState<StaffMember[]>([
|
||||
{
|
||||
id: 'maria-lopez',
|
||||
name: 'María López',
|
||||
role: 'baker',
|
||||
currentTask: 'Preparando croissants',
|
||||
status: 'busy',
|
||||
efficiency: 94.2
|
||||
},
|
||||
{
|
||||
id: 'carlos-ruiz',
|
||||
name: 'Carlos Ruiz',
|
||||
role: 'assistant',
|
||||
currentTask: 'Horneando croissants',
|
||||
status: 'busy',
|
||||
efficiency: 87.8
|
||||
},
|
||||
{
|
||||
id: 'ana-garcia',
|
||||
name: 'Ana García',
|
||||
role: 'baker',
|
||||
status: 'available',
|
||||
efficiency: 91.5
|
||||
}
|
||||
]);
|
||||
|
||||
const [equipment, setEquipment] = useState<Equipment[]>([
|
||||
{
|
||||
id: 'oven-1',
|
||||
name: 'Horno Principal',
|
||||
type: 'oven',
|
||||
status: 'in_use',
|
||||
currentBatch: 'batch-1',
|
||||
temperature: 180,
|
||||
maintenanceDue: '2024-11-15'
|
||||
},
|
||||
{
|
||||
id: 'mixer-1',
|
||||
name: 'Amasadora Industrial',
|
||||
type: 'mixer',
|
||||
status: 'idle',
|
||||
maintenanceDue: '2024-11-20'
|
||||
},
|
||||
{
|
||||
id: 'proofer-1',
|
||||
name: 'Fermentadora',
|
||||
type: 'proofer',
|
||||
status: 'in_use',
|
||||
currentBatch: 'batch-2',
|
||||
temperature: 28,
|
||||
maintenanceDue: '2024-12-01'
|
||||
}
|
||||
]);
|
||||
|
||||
// Demand heatmap sample data
|
||||
const heatmapData = [
|
||||
{
|
||||
weekStart: '2024-11-04',
|
||||
days: [
|
||||
{
|
||||
date: '2024-11-04',
|
||||
demand: 180,
|
||||
isToday: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 48, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 35, confidence: 'high' as const },
|
||||
{ name: 'Baguettes', demand: 25, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 32, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2024-11-05',
|
||||
demand: 165,
|
||||
isForecast: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 42, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 38, confidence: 'medium' as const },
|
||||
{ name: 'Baguettes', demand: 28, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 28, confidence: 'low' as const },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2024-11-06',
|
||||
demand: 195,
|
||||
isForecast: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 55, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 40, confidence: 'high' as const },
|
||||
{ name: 'Baguettes', demand: 32, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 35, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{ date: '2024-11-07', demand: 220, isForecast: true },
|
||||
{ date: '2024-11-08', demand: 185, isForecast: true },
|
||||
{ date: '2024-11-09', demand: 250, isForecast: true },
|
||||
{ date: '2024-11-10', demand: 160, isForecast: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'in_progress':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'delayed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getEquipmentStatusColor = (status: Equipment['status']) => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'in_use':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'maintenance':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-gray-50 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<ChefHat className="h-8 w-8 mr-3 text-primary-600" />
|
||||
Centro de Producción
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestión completa de la producción diaria y planificación inteligente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:mt-0 flex items-center space-x-4">
|
||||
<div className="bg-gray-50 rounded-lg px-4 py-2">
|
||||
<div className="text-sm font-medium text-gray-900">Eficiencia Hoy</div>
|
||||
<div className="text-2xl font-bold text-primary-600">{productionMetrics.efficiency}%</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Nuevo Lote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Eficiencia</p>
|
||||
<p className="text-2xl font-bold text-green-600">{productionMetrics.efficiency}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<Target className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-green-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+2.3% vs ayer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">A Tiempo</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{productionMetrics.onTimeCompletion}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-blue-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Muy bueno
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{productionMetrics.wastePercentage}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-orange-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
-0.5% vs ayer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Energía</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{productionMetrics.energyUsage} kW</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Zap className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-purple-600">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Normal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Personal</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{productionMetrics.staffUtilization}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-100 rounded-lg">
|
||||
<Users className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-indigo-600">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
3/4 activos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'schedule', label: 'Programa', icon: Calendar },
|
||||
{ id: 'batches', label: 'Lotes Activos', icon: Timer },
|
||||
{ id: 'analytics', label: 'Análisis', icon: BarChart3 },
|
||||
{ id: 'staff', label: 'Personal', icon: Users },
|
||||
{ id: 'equipment', label: 'Equipos', icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium rounded-lg transition-all flex items-center justify-center ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{activeTab === 'schedule' && (
|
||||
<>
|
||||
<ProductionSchedule
|
||||
schedule={productionSchedule}
|
||||
onUpdateQuantity={(itemId, quantity) => {
|
||||
setProductionSchedule(prev =>
|
||||
prev.map(slot => ({
|
||||
...slot,
|
||||
items: slot.items.map(item =>
|
||||
item.id === itemId ? { ...item, quantity } : item
|
||||
)
|
||||
}))
|
||||
);
|
||||
}}
|
||||
onUpdateStatus={(itemId, status) => {
|
||||
setProductionSchedule(prev =>
|
||||
prev.map(slot => ({
|
||||
...slot,
|
||||
items: slot.items.map(item =>
|
||||
item.id === itemId ? { ...item, status } : item
|
||||
)
|
||||
}))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{productionBatches.map((batch) => (
|
||||
<div key={batch.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{batch.product}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(batch.status)}`}>
|
||||
{batch.status === 'planned' ? 'Planificado' :
|
||||
batch.status === 'in_progress' ? 'En Progreso' :
|
||||
batch.status === 'completed' ? 'Completado' : 'Retrasado'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tamaño del Lote</p>
|
||||
<p className="font-semibold text-gray-900">{batch.batchSize} unidades</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rendimiento</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{batch.actualYield || 0}/{batch.expectedYield}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Inicio</p>
|
||||
<p className="font-semibold text-gray-900">{batch.startTime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Fin Estimado</p>
|
||||
<p className="font-semibold text-gray-900">{batch.endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(batch.temperature || batch.humidity) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{batch.temperature && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Temperatura</p>
|
||||
<p className="font-semibold text-gray-900">{batch.temperature}°C</p>
|
||||
</div>
|
||||
)}
|
||||
{batch.humidity && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Humedad</p>
|
||||
<p className="font-semibold text-gray-900">{batch.humidity}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Personal Asignado</p>
|
||||
<div className="flex space-x-2">
|
||||
{batch.assignedStaff.map((staffId) => {
|
||||
const staffMember = staff.find(s => s.id === staffId);
|
||||
return (
|
||||
<span
|
||||
key={staffId}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded"
|
||||
>
|
||||
{staffMember?.name || staffId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batch.notes && (
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700">{batch.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<DemandHeatmap
|
||||
data={heatmapData}
|
||||
onDateClick={(date) => {
|
||||
console.log('Selected date:', date);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Production Trends Chart Placeholder */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Tendencias de Producción
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>Gráfico de tendencias de producción</p>
|
||||
<p className="text-sm">Próximamente disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'staff' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{staff.map((member) => (
|
||||
<div key={member.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{member.name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
member.status === 'available' ? 'bg-green-100 text-green-800' :
|
||||
member.status === 'busy' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{member.status === 'available' ? 'Disponible' :
|
||||
member.status === 'busy' ? 'Ocupado' : 'Descanso'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rol</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{member.role}</p>
|
||||
</div>
|
||||
|
||||
{member.currentTask && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tarea Actual</p>
|
||||
<p className="font-medium text-gray-900">{member.currentTask}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Eficiencia</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full"
|
||||
style={{ width: `${member.efficiency}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{member.efficiency}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'equipment' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{equipment.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{item.name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getEquipmentStatusColor(item.status)}`}>
|
||||
{item.status === 'idle' ? 'Inactivo' :
|
||||
item.status === 'in_use' ? 'En Uso' :
|
||||
item.status === 'maintenance' ? 'Mantenimiento' : 'Error'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tipo</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{item.type}</p>
|
||||
</div>
|
||||
|
||||
{item.currentBatch && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Lote Actual</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{productionBatches.find(b => b.id === item.currentBatch)?.product || item.currentBatch}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.temperature && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Temperatura</p>
|
||||
<p className="font-medium text-gray-900">{item.temperature}°C</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.maintenanceDue && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Próximo Mantenimiento</p>
|
||||
<p className="font-medium text-orange-600">
|
||||
{new Date(item.maintenanceDue).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPage;
|
||||
710
frontend/src/pages/public/LandingPage.tsx
Normal file
710
frontend/src/pages/public/LandingPage.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Zap,
|
||||
Users,
|
||||
Award,
|
||||
ChevronRight,
|
||||
Check,
|
||||
Star,
|
||||
ArrowRight,
|
||||
Play,
|
||||
Calendar,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Package,
|
||||
PieChart,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="full-width"
|
||||
contentPadding="none"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: true,
|
||||
variant: "default",
|
||||
navigationItems: [
|
||||
{ id: 'features', label: 'Características', href: '#features' },
|
||||
{ id: 'benefits', label: 'Beneficios', href: '#benefits' },
|
||||
{ id: 'pricing', label: 'Precios', href: '#pricing' },
|
||||
{ id: 'testimonials', label: 'Testimonios', href: '#testimonials' }
|
||||
]
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
IA Avanzada para Panaderías
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl">
|
||||
<span className="block">Revoluciona tu</span>
|
||||
<span className="block text-[var(--color-primary)]">Panadería con IA</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
|
||||
Optimiza automáticamente tu producción, reduce desperdicios hasta un 35%,
|
||||
predice demanda con precisión del 92% y aumenta tus ventas con inteligencia artificial.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/register">
|
||||
<Button size="lg" className="px-8 py-4 text-lg font-semibold bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200">
|
||||
Comenzar Gratis 14 Días
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="px-8 py-4 text-lg font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200"
|
||||
onClick={() => scrollToSection('demo')}
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Ver Demo en Vivo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-center space-x-6 text-sm text-[var(--text-tertiary)]">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Sin tarjeta de crédito
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Configuración en 5 minutos
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Soporte 24/7 en español
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 left-0 right-0 h-full overflow-hidden -z-10">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-[var(--color-primary)]/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-[var(--color-secondary)]/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-16 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">500+</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Panaderías Activas</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">35%</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Reducción de Desperdicios</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">92%</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Precisión de Predicciones</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">4.8★</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Satisfacción de Clientes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Features Section */}
|
||||
<section id="features" className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)]">
|
||||
Gestión Completa con
|
||||
<span className="block text-[var(--color-primary)]">Inteligencia Artificial</span>
|
||||
</h2>
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Automatiza procesos, optimiza recursos y toma decisiones inteligentes basadas en datos reales de tu panadería.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* AI Forecasting */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Predicción Inteligente</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Algoritmos de IA analizan patrones históricos, clima, eventos locales y tendencias para predecir la demanda exacta de cada producto.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Precisión del 92% en predicciones
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Reduce desperdicios hasta 35%
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Aumenta ventas promedio 22%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Inventory */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Inventario Inteligente</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Control automático de stock con alertas predictivas, órdenes de compra automatizadas y optimización de costos.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Alertas automáticas de stock bajo
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Órdenes de compra automatizadas
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Optimización de costos de materias primas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Planning */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Calendar className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Planificación de Producción</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Programa automáticamente la producción diaria basada en predicciones, optimiza horarios y recursos disponibles.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Programación automática de horneado
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Optimización de uso de hornos
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Gestión de personal y turnos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Features Grid */}
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<BarChart3 className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">Analytics Avanzado</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Dashboards en tiempo real con métricas clave</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-secondary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<DollarSign className="w-6 h-6 text-[var(--color-secondary)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">POS Integrado</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Sistema de ventas completo y fácil de usar</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-6 h-6 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">Control de Calidad</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Trazabilidad completa y gestión HACCP</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-info)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="w-6 h-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">Automatización</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Procesos automáticos que ahorran tiempo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section id="benefits" className="py-24 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="lg:grid lg:grid-cols-2 lg:gap-16 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Resultados Comprobados
|
||||
<span className="block text-[var(--color-primary)]">en Cientos de Panaderías</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-[var(--text-secondary)]">
|
||||
Nuestros clientes han logrado transformaciones significativas en sus operaciones,
|
||||
mejorando rentabilidad y reduciendo desperdicios desde el primer mes.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)]">Aumenta Ventas 22% Promedio</h4>
|
||||
<p className="text-[var(--text-secondary)]">Optimización de producción y mejor disponibilidad de productos populares</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)]">Reduce Desperdicios 35%</h4>
|
||||
<p className="text-[var(--text-secondary)]">Predicciones precisas evitan sobreproducción y productos vencidos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)]">Ahorra 8 Horas Semanales</h4>
|
||||
<p className="text-[var(--text-secondary)]">Automatización de tareas administrativas y de planificación</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 lg:mt-0">
|
||||
<div className="bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-secondary)]/10 rounded-2xl p-8">
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">€127k</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Ahorro promedio anual por panadería</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-secondary)]">98%</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Satisfacción de clientes</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-accent)]">2.3x</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">ROI promedio en 12 meses</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-info)]">24/7</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Soporte técnico especializado</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section id="testimonials" className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Lo que Dicen Nuestros Clientes
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Panaderías de toda España han transformado sus negocios con nuestra plataforma
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Testimonial 1 */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-[var(--text-secondary)] italic">
|
||||
"Desde que implementamos Panadería IA, nuestros desperdicios se redujeron un 40% y las ventas aumentaron un 28%.
|
||||
La predicción de demanda es increíblemente precisa."
|
||||
</blockquote>
|
||||
<div className="mt-6 flex items-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white font-bold">
|
||||
M
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-[var(--text-primary)]">María González</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Panadería Santa María, Madrid</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial 2 */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-[var(--text-secondary)] italic">
|
||||
"El sistema nos ahorra 10 horas semanales en planificación. Ahora puedo enfocarme en mejorar nuestros productos
|
||||
mientras la IA maneja la logística."
|
||||
</blockquote>
|
||||
<div className="mt-6 flex items-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-secondary)] rounded-full flex items-center justify-center text-white font-bold">
|
||||
C
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-[var(--text-primary)]">Carlos Ruiz</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Horno de Oro, Valencia</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial 3 */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-[var(--text-secondary)] italic">
|
||||
"Increíble cómo predice exactamente cuántos panes necesitamos cada día. Nuestros clientes siempre encuentran
|
||||
sus productos favoritos disponibles."
|
||||
</blockquote>
|
||||
<div className="mt-6 flex items-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-accent)] rounded-full flex items-center justify-center text-white font-bold">
|
||||
A
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-[var(--text-primary)]">Ana Martínez</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Pan & Tradición, Sevilla</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust indicators */}
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-sm text-[var(--text-tertiary)] mb-8">Confiado por más de 500 panaderías en España</p>
|
||||
<div className="flex items-center justify-center space-x-8 opacity-60">
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Panadería Real</div>
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Horno Artesanal</div>
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Pan de Casa</div>
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Dulce Tradición</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Planes que se Adaptan a tu Negocio
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Starter Plan */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Starter</h3>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Perfecto para panaderías pequeñas</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">€49</span>
|
||||
<span className="text-[var(--text-secondary)]">/mes</span>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Hasta 50 productos</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Predicción básica de demanda</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Control de inventario</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Reportes básicos</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Soporte por email</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-8" variant="outline">
|
||||
Comenzar Gratis
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Professional Plan - Highlighted */}
|
||||
<div className="bg-gradient-to-b from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-2xl p-8 relative shadow-2xl">
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<span className="bg-[var(--color-secondary)] text-white px-4 py-1 rounded-full text-sm font-semibold">
|
||||
Más Popular
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Professional</h3>
|
||||
<p className="mt-2 text-sm text-white/80">Para panaderías en crecimiento</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-3xl font-bold text-white">€149</span>
|
||||
<span className="text-white/80">/mes</span>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Productos ilimitados</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">IA avanzada con 92% precisión</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Gestión completa de producción</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">POS integrado</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Analytics avanzado</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Soporte prioritario 24/7</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-8 bg-white text-[var(--color-primary)] hover:bg-[var(--bg-tertiary)]">
|
||||
Comenzar Prueba Gratuita
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Enterprise</h3>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Para cadenas y grandes operaciones</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">€399</span>
|
||||
<span className="text-[var(--text-secondary)]">/mes</span>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Multi-locación ilimitada</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">IA personalizada por ubicación</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">API personalizada</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Integración ERPs</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Manager dedicado</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-8" variant="outline">
|
||||
Contactar Ventas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
🔒 Todos los planes incluyen cifrado de datos, backups automáticos y cumplimiento RGPD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Preguntas Frecuentes
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-[var(--text-secondary)]">
|
||||
Todo lo que necesitas saber sobre Panadería IA
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-8">
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Qué tan precisa es la predicción de demanda?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Nuestra IA alcanza una precisión del 92% en predicciones de demanda, analizando más de 50 variables incluyendo
|
||||
histórico de ventas, clima, eventos locales, estacionalidad y tendencias de mercado. La precisión mejora continuamente
|
||||
con más datos de tu panadería.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Cuánto tiempo toma implementar el sistema?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
La configuración inicial toma solo 5 minutos. Nuestro equipo te ayuda a migrar tus datos históricos en 24-48 horas.
|
||||
La IA comienza a generar predicciones útiles después de una semana de datos, alcanzando máxima precisión en 30 días.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Se integra con mi sistema POS actual?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Sí, nos integramos con más de 50 sistemas POS populares en España. También incluimos nuestro propio POS optimizado
|
||||
para panaderías. Si usas un sistema específico, nuestro equipo técnico puede crear una integración personalizada.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Qué soporte técnico ofrecen?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Ofrecemos soporte 24/7 en español por chat, email y teléfono. Todos nuestros técnicos son expertos en operaciones
|
||||
de panadería. Además, incluimos onboarding personalizado y training para tu equipo sin costo adicional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Mis datos están seguros?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Absolutamente. Utilizamos cifrado AES-256, servidores en la UE, cumplimos 100% con RGPD y realizamos auditorías
|
||||
de seguridad trimestrales. Tus datos nunca se comparten con terceros y tienes control total sobre tu información.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA Section */}
|
||||
<section className="py-24 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8 relative">
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-white">
|
||||
Transforma tu Panadería
|
||||
<span className="block text-white/90">Comenzando Hoy</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-white/80 max-w-2xl mx-auto">
|
||||
Únete a más de 500 panaderías que ya están reduciendo desperdicios, aumentando ventas y
|
||||
optimizando operaciones con inteligencia artificial.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex flex-col sm:flex-row gap-6 justify-center">
|
||||
<Link to="/register">
|
||||
<Button
|
||||
size="lg"
|
||||
className="px-10 py-4 text-lg font-semibold bg-white text-[var(--color-primary)] hover:bg-[var(--bg-tertiary)] shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Comenzar Prueba Gratuita 14 Días
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="px-10 py-4 text-lg font-semibold border-2 border-white text-white hover:bg-white hover:text-[var(--color-primary)] transition-all duration-200"
|
||||
onClick={() => scrollToSection('demo')}
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Ver Demo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">14 días</div>
|
||||
<div className="text-white/70 text-sm">Prueba gratuita</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">5 min</div>
|
||||
<div className="text-white/70 text-sm">Configuración</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">24/7</div>
|
||||
<div className="text-white/70 text-sm">Soporte incluido</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
710
frontend/src/pages/public/LandingPage.tsx.backup
Normal file
710
frontend/src/pages/public/LandingPage.tsx.backup
Normal file
@@ -0,0 +1,710 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Zap,
|
||||
Users,
|
||||
Award,
|
||||
ChevronRight,
|
||||
Check,
|
||||
Star,
|
||||
ArrowRight,
|
||||
Play,
|
||||
Calendar,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Package,
|
||||
PieChart,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="full-width"
|
||||
contentPadding="none"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: true,
|
||||
variant: "default",
|
||||
navigationItems: [
|
||||
{ id: 'features', label: 'Características', href: '#features' },
|
||||
{ id: 'benefits', label: 'Beneficios', href: '#benefits' },
|
||||
{ id: 'pricing', label: 'Precios', href: '#pricing' },
|
||||
{ id: 'testimonials', label: 'Testimonios', href: '#testimonials' }
|
||||
]
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
IA Avanzada para Panaderías
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl">
|
||||
<span className="block">Revoluciona tu</span>
|
||||
<span className="block text-[var(--color-primary)]">Panadería con IA</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
|
||||
Optimiza automáticamente tu producción, reduce desperdicios hasta un 35%,
|
||||
predice demanda con precisión del 92% y aumenta tus ventas con inteligencia artificial.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/register">
|
||||
<Button size="lg" className="px-8 py-4 text-lg font-semibold bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200">
|
||||
Comenzar Gratis 14 Días
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="px-8 py-4 text-lg font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200"
|
||||
onClick={() => scrollToSection('demo')}
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Ver Demo en Vivo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-center space-x-6 text-sm text-[var(--text-tertiary)]">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Sin tarjeta de crédito
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Configuración en 5 minutos
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Soporte 24/7 en español
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 left-0 right-0 h-full overflow-hidden -z-10">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-[var(--color-primary)]/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-[var(--color-secondary)]/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-16 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">500+</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Panaderías Activas</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">35%</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Reducción de Desperdicios</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">92%</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Precisión de Predicciones</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">4.8★</div>
|
||||
<div className="mt-2 text-sm text-[var(--text-secondary)]">Satisfacción de Clientes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Features Section */}
|
||||
<section id="features" className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)]">
|
||||
Gestión Completa con
|
||||
<span className="block text-[var(--color-primary)]">Inteligencia Artificial</span>
|
||||
</h2>
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Automatiza procesos, optimiza recursos y toma decisiones inteligentes basadas en datos reales de tu panadería.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* AI Forecasting */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Predicción Inteligente</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Algoritmos de IA analizan patrones históricos, clima, eventos locales y tendencias para predecir la demanda exacta de cada producto.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Precisión del 92% en predicciones
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Reduce desperdicios hasta 35%
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Aumenta ventas promedio 22%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Inventory */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Inventario Inteligente</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Control automático de stock con alertas predictivas, órdenes de compra automatizadas y optimización de costos.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Alertas automáticas de stock bajo
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Órdenes de compra automatizadas
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Optimización de costos de materias primas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Planning */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Calendar className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Planificación de Producción</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Programa automáticamente la producción diaria basada en predicciones, optimiza horarios y recursos disponibles.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Programación automática de horneado
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Optimización de uso de hornos
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Gestión de personal y turnos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Features Grid */}
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<BarChart3 className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">Analytics Avanzado</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Dashboards en tiempo real con métricas clave</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-secondary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<DollarSign className="w-6 h-6 text-[var(--color-secondary)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">POS Integrado</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Sistema de ventas completo y fácil de usar</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-6 h-6 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">Control de Calidad</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Trazabilidad completa y gestión HACCP</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-info)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="w-6 h-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">Automatización</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">Procesos automáticos que ahorran tiempo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section id="benefits" className="py-24 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="lg:grid lg:grid-cols-2 lg:gap-16 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Resultados Comprobados
|
||||
<span className="block text-[var(--color-primary)]">en Cientos de Panaderías</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-[var(--text-secondary)]">
|
||||
Nuestros clientes han logrado transformaciones significativas en sus operaciones,
|
||||
mejorando rentabilidad y reduciendo desperdicios desde el primer mes.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)]">Aumenta Ventas 22% Promedio</h4>
|
||||
<p className="text-[var(--text-secondary)]">Optimización de producción y mejor disponibilidad de productos populares</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)]">Reduce Desperdicios 35%</h4>
|
||||
<p className="text-[var(--text-secondary)]">Predicciones precisas evitan sobreproducción y productos vencidos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)]">Ahorra 8 Horas Semanales</h4>
|
||||
<p className="text-[var(--text-secondary)]">Automatización de tareas administrativas y de planificación</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 lg:mt-0">
|
||||
<div className="bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-secondary)]/10 rounded-2xl p-8">
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)]">€127k</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Ahorro promedio anual por panadería</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-secondary)]">98%</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Satisfacción de clientes</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-accent)]">2.3x</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">ROI promedio en 12 meses</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-info)]">24/7</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Soporte técnico especializado</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section id="testimonials" className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Lo que Dicen Nuestros Clientes
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Panaderías de toda España han transformado sus negocios con nuestra plataforma
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Testimonial 1 */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-[var(--text-secondary)] italic">
|
||||
"Desde que implementamos Panadería IA, nuestros desperdicios se redujeron un 40% y las ventas aumentaron un 28%.
|
||||
La predicción de demanda es increíblemente precisa."
|
||||
</blockquote>
|
||||
<div className="mt-6 flex items-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white font-bold">
|
||||
M
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-[var(--text-primary)]">María González</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Panadería Santa María, Madrid</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial 2 */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-[var(--text-secondary)] italic">
|
||||
"El sistema nos ahorra 10 horas semanales en planificación. Ahora puedo enfocarme en mejorar nuestros productos
|
||||
mientras la IA maneja la logística."
|
||||
</blockquote>
|
||||
<div className="mt-6 flex items-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-secondary)] rounded-full flex items-center justify-center text-white font-bold">
|
||||
C
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-[var(--text-primary)]">Carlos Ruiz</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Horno de Oro, Valencia</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial 3 */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg border border-[var(--border-primary)]">
|
||||
<div className="flex items-center mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-[var(--text-secondary)] italic">
|
||||
"Increíble cómo predice exactamente cuántos panes necesitamos cada día. Nuestros clientes siempre encuentran
|
||||
sus productos favoritos disponibles."
|
||||
</blockquote>
|
||||
<div className="mt-6 flex items-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-accent)] rounded-full flex items-center justify-center text-white font-bold">
|
||||
A
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-[var(--text-primary)]">Ana Martínez</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Pan & Tradición, Sevilla</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust indicators */}
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-sm text-[var(--text-tertiary)] mb-8">Confiado por más de 500 panaderías en España</p>
|
||||
<div className="flex items-center justify-center space-x-8 opacity-60">
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Panadería Real</div>
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Horno Artesanal</div>
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Pan de Casa</div>
|
||||
<div className="font-semibold text-[var(--text-secondary)]">Dulce Tradición</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Planes que se Adaptan a tu Negocio
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Starter Plan */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Starter</h3>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Perfecto para panaderías pequeñas</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">€49</span>
|
||||
<span className="text-[var(--text-secondary)]">/mes</span>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Hasta 50 productos</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Predicción básica de demanda</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Control de inventario</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Reportes básicos</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Soporte por email</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-8" variant="outline">
|
||||
Comenzar Gratis
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Professional Plan - Highlighted */}
|
||||
<div className="bg-gradient-to-b from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-2xl p-8 relative shadow-2xl">
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<span className="bg-[var(--color-secondary)] text-white px-4 py-1 rounded-full text-sm font-semibold">
|
||||
Más Popular
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Professional</h3>
|
||||
<p className="mt-2 text-sm text-white/80">Para panaderías en crecimiento</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-3xl font-bold text-white">€149</span>
|
||||
<span className="text-white/80">/mes</span>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Productos ilimitados</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">IA avanzada con 92% precisión</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Gestión completa de producción</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">POS integrado</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Analytics avanzado</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-white mr-3" />
|
||||
<span className="text-sm text-white">Soporte prioritario 24/7</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-8 bg-white text-[var(--color-primary)] hover:bg-gray-100">
|
||||
Comenzar Prueba Gratuita
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Enterprise</h3>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Para cadenas y grandes operaciones</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">€399</span>
|
||||
<span className="text-[var(--text-secondary)]">/mes</span>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Multi-locación ilimitada</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">IA personalizada por ubicación</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">API personalizada</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Integración ERPs</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Check className="w-5 h-5 text-green-500 mr-3" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Manager dedicado</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-8" variant="outline">
|
||||
Contactar Ventas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
🔒 Todos los planes incluyen cifrado de datos, backups automáticos y cumplimiento RGPD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||
Preguntas Frecuentes
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-[var(--text-secondary)]">
|
||||
Todo lo que necesitas saber sobre Panadería IA
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-8">
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Qué tan precisa es la predicción de demanda?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Nuestra IA alcanza una precisión del 92% en predicciones de demanda, analizando más de 50 variables incluyendo
|
||||
histórico de ventas, clima, eventos locales, estacionalidad y tendencias de mercado. La precisión mejora continuamente
|
||||
con más datos de tu panadería.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Cuánto tiempo toma implementar el sistema?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
La configuración inicial toma solo 5 minutos. Nuestro equipo te ayuda a migrar tus datos históricos en 24-48 horas.
|
||||
La IA comienza a generar predicciones útiles después de una semana de datos, alcanzando máxima precisión en 30 días.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Se integra con mi sistema POS actual?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Sí, nos integramos con más de 50 sistemas POS populares en España. También incluimos nuestro propio POS optimizado
|
||||
para panaderías. Si usas un sistema específico, nuestro equipo técnico puede crear una integración personalizada.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Qué soporte técnico ofrecen?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Ofrecemos soporte 24/7 en español por chat, email y teléfono. Todos nuestros técnicos son expertos en operaciones
|
||||
de panadería. Además, incluimos onboarding personalizado y training para tu equipo sin costo adicional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-primary)] rounded-xl p-8 border border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
¿Mis datos están seguros?
|
||||
</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Absolutamente. Utilizamos cifrado AES-256, servidores en la UE, cumplimos 100% con RGPD y realizamos auditorías
|
||||
de seguridad trimestrales. Tus datos nunca se comparten con terceros y tienes control total sobre tu información.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA Section */}
|
||||
<section className="py-24 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8 relative">
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-white">
|
||||
Transforma tu Panadería
|
||||
<span className="block text-white/90">Comenzando Hoy</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-white/80 max-w-2xl mx-auto">
|
||||
Únete a más de 500 panaderías que ya están reduciendo desperdicios, aumentando ventas y
|
||||
optimizando operaciones con inteligencia artificial.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex flex-col sm:flex-row gap-6 justify-center">
|
||||
<Link to="/register">
|
||||
<Button
|
||||
size="lg"
|
||||
className="px-10 py-4 text-lg font-semibold bg-white text-[var(--color-primary)] hover:bg-gray-100 shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Comenzar Prueba Gratuita 14 Días
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="px-10 py-4 text-lg font-semibold border-2 border-white text-white hover:bg-white hover:text-[var(--color-primary)] transition-all duration-200"
|
||||
onClick={() => scrollToSection('demo')}
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Ver Demo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">14 días</div>
|
||||
<div className="text-white/70 text-sm">Prueba gratuita</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">5 min</div>
|
||||
<div className="text-white/70 text-sm">Configuración</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">24/7</div>
|
||||
<div className="text-white/70 text-sm">Soporte incluido</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
201
frontend/src/pages/public/LoginPage.tsx
Normal file
201
frontend/src/pages/public/LoginPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthActions, useAuthError, useAuthLoading, useIsAuthenticated } from '../../stores';
|
||||
import { Button, Input, Card } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { login } = useAuthActions();
|
||||
const error = useAuthError();
|
||||
const loading = useAuthLoading();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const from = (location.state as any)?.from?.pathname || '/app';
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !loading) {
|
||||
// Add a small delay to ensure the auth state has fully settled
|
||||
setTimeout(() => {
|
||||
navigate(from, { replace: true });
|
||||
}, 100);
|
||||
}
|
||||
}, [isAuthenticated, loading, navigate, from]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err) {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="centered"
|
||||
maxWidth="md"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: false,
|
||||
variant: "minimal"
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md mx-auto space-y-8">
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
PI
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-[var(--text-primary)]">
|
||||
Inicia sesión en tu cuenta
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-[var(--text-secondary)]">
|
||||
O{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-light)]"
|
||||
>
|
||||
regístrate para comenzar tu prueba gratuita
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-8">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-[var(--color-error)]" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-[var(--color-error)]">
|
||||
Error de autenticación
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Correo electrónico"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Contraseña
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="Contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[var(--color-primary)] focus:ring-[var(--color-primary)] border-[var(--border-primary)] rounded"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-[var(--text-primary)]">
|
||||
Recordarme
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a href="#" className="font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-light)]">
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full flex justify-center"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-[var(--border-primary)]" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-[var(--bg-primary)] text-[var(--text-tertiary)]">Demo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// TODO: Handle demo login
|
||||
console.log('Demo login');
|
||||
}}
|
||||
>
|
||||
Usar cuenta de demo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-[var(--text-tertiary)]">
|
||||
Al iniciar sesión, aceptas nuestros{' '}
|
||||
<a href="#" className="text-[var(--color-primary)] hover:text-[var(--color-primary-light)]">
|
||||
Términos de Servicio
|
||||
</a>
|
||||
{' '}y{' '}
|
||||
<a href="#" className="text-[var(--color-primary)] hover:text-[var(--color-primary-light)]">
|
||||
Política de Privacidad
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
376
frontend/src/pages/public/RegisterPage.tsx
Normal file
376
frontend/src/pages/public/RegisterPage.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button, Input, Card, Select } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
// Personal info
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
|
||||
// Company info
|
||||
companyName: '',
|
||||
companyType: '',
|
||||
employeeCount: '',
|
||||
|
||||
// Account info
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
acceptMarketing: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
setStep(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handlePrevStep = () => {
|
||||
setStep(prev => prev - 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Redirect to onboarding
|
||||
navigate('/onboarding');
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isStep1Valid = formData.firstName && formData.lastName && formData.email && formData.phone;
|
||||
const isStep2Valid = formData.companyName && formData.companyType && formData.employeeCount;
|
||||
const isStep3Valid = formData.password && formData.confirmPassword &&
|
||||
formData.password === formData.confirmPassword && formData.acceptTerms;
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="centered"
|
||||
maxWidth="md"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: false,
|
||||
variant: "minimal"
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md mx-auto space-y-8">
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
PI
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-[var(--text-primary)]">
|
||||
Crea tu cuenta
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-[var(--text-secondary)]">
|
||||
O{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-light)]"
|
||||
>
|
||||
inicia sesión si ya tienes una cuenta
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-8">
|
||||
{/* Progress indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center">
|
||||
{[1, 2, 3].map((stepNumber) => (
|
||||
<div key={stepNumber} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
step >= stepNumber
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--bg-quaternary)] text-[var(--text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{stepNumber}
|
||||
</div>
|
||||
{stepNumber < 3 && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-4 ${
|
||||
step > stepNumber ? 'bg-[var(--color-primary)]' : 'bg-[var(--bg-quaternary)]'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-[var(--text-secondary)]">
|
||||
<span>Datos personales</span>
|
||||
<span>Información empresarial</span>
|
||||
<span>Crear cuenta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Nombre *
|
||||
</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
placeholder="Tu nombre"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Apellido *
|
||||
</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||
placeholder="Tu apellido"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Correo electrónico *
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Teléfono *
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="+34 600 000 000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
disabled={!isStep1Valid}
|
||||
className="w-full"
|
||||
>
|
||||
Continuar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="companyName" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Nombre de la panadería *
|
||||
</label>
|
||||
<Input
|
||||
id="companyName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleInputChange('companyName', e.target.value)}
|
||||
placeholder="Panadería San Miguel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="companyType" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Tipo de negocio *
|
||||
</label>
|
||||
<Select
|
||||
value={formData.companyType}
|
||||
onValueChange={(value) => handleInputChange('companyType', value)}
|
||||
>
|
||||
<option value="">Selecciona el tipo</option>
|
||||
<option value="traditional">Panadería tradicional</option>
|
||||
<option value="artisan">Panadería artesanal</option>
|
||||
<option value="industrial">Panadería industrial</option>
|
||||
<option value="bakery-cafe">Panadería-cafetería</option>
|
||||
<option value="specialty">Panadería especializada</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="employeeCount" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Número de empleados *
|
||||
</label>
|
||||
<Select
|
||||
value={formData.employeeCount}
|
||||
onValueChange={(value) => handleInputChange('employeeCount', value)}
|
||||
>
|
||||
<option value="">Selecciona el rango</option>
|
||||
<option value="1">Solo yo</option>
|
||||
<option value="2-5">2-5 empleados</option>
|
||||
<option value="6-15">6-15 empleados</option>
|
||||
<option value="16-50">16-50 empleados</option>
|
||||
<option value="51+">Más de 50 empleados</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handlePrevStep}
|
||||
className="flex-1"
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
disabled={!isStep2Valid}
|
||||
className="flex-1"
|
||||
>
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Contraseña *
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||
Confirmar contraseña *
|
||||
</label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
placeholder="Repite la contraseña"
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-[var(--color-error)]">Las contraseñas no coinciden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[var(--color-primary)] focus:ring-[var(--color-primary)] border-[var(--border-secondary)] rounded mt-0.5"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={(e) => handleInputChange('acceptTerms', e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-[var(--text-primary)]">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="text-[var(--color-primary)] hover:text-[var(--color-primary-light)]">
|
||||
Términos de Servicio
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="#" className="text-[var(--color-primary)] hover:text-[var(--color-primary-light)]">
|
||||
Política de Privacidad
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="acceptMarketing"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[var(--color-primary)] focus:ring-[var(--color-primary)] border-[var(--border-secondary)] rounded mt-0.5"
|
||||
checked={formData.acceptMarketing}
|
||||
onChange={(e) => handleInputChange('acceptMarketing', e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="acceptMarketing" className="ml-2 block text-sm text-[var(--text-primary)]">
|
||||
Quiero recibir newsletters y novedades sobre el producto (opcional)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handlePrevStep}
|
||||
className="flex-1"
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isStep3Valid || loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? 'Creando cuenta...' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-[var(--text-secondary)]">
|
||||
¿Necesitas ayuda?{' '}
|
||||
<a href="#" className="text-[var(--color-primary)] hover:text-[var(--color-primary-light)]">
|
||||
Contáctanos
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
3
frontend/src/pages/public/index.ts
Normal file
3
frontend/src/pages/public/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as LandingPage } from './LandingPage';
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
@@ -1,521 +0,0 @@
|
||||
// frontend/src/pages/recipes/RecipesPage.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ChefHat,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Loader,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
Star,
|
||||
Calendar,
|
||||
Download,
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { useRecipes } from '../../api/hooks/useRecipes';
|
||||
import { Recipe, RecipeSearchParams } from '../../api/services/recipes.service';
|
||||
import RecipeCard from '../../components/recipes/RecipeCard';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
interface FilterState {
|
||||
search: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
is_seasonal?: boolean;
|
||||
is_signature?: boolean;
|
||||
difficulty_level?: number;
|
||||
}
|
||||
|
||||
interface RecipesPageProps {
|
||||
view?: string;
|
||||
}
|
||||
|
||||
const RecipesPage: React.FC<RecipesPageProps> = ({ view }) => {
|
||||
const {
|
||||
recipes,
|
||||
categories,
|
||||
statistics,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
pagination,
|
||||
loadRecipes,
|
||||
createRecipe,
|
||||
updateRecipe,
|
||||
deleteRecipe,
|
||||
duplicateRecipe,
|
||||
activateRecipe,
|
||||
checkFeasibility,
|
||||
loadStatistics,
|
||||
clearError,
|
||||
refresh,
|
||||
setPage
|
||||
} = useRecipes();
|
||||
|
||||
// Local state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: ''
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedRecipes, setSelectedRecipes] = useState<Set<string>>(new Set());
|
||||
const [feasibilityResults, setFeasibilityResults] = useState<Map<string, any>>(new Map());
|
||||
|
||||
// Load recipes when filters change
|
||||
useEffect(() => {
|
||||
const searchParams: RecipeSearchParams = {
|
||||
search_term: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
category: filters.category || undefined,
|
||||
is_seasonal: filters.is_seasonal,
|
||||
is_signature: filters.is_signature,
|
||||
difficulty_level: filters.difficulty_level,
|
||||
limit: 20,
|
||||
offset: (pagination.page - 1) * 20
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(searchParams).forEach(key => {
|
||||
if (searchParams[key as keyof RecipeSearchParams] === undefined) {
|
||||
delete searchParams[key as keyof RecipeSearchParams];
|
||||
}
|
||||
});
|
||||
|
||||
loadRecipes(searchParams);
|
||||
}, [filters, pagination.page, loadRecipes]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setFilters(prev => ({ ...prev, search: value }));
|
||||
setPage(1); // Reset to first page
|
||||
}, [setPage]);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setPage(1); // Reset to first page
|
||||
}, [setPage]);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({ search: '' });
|
||||
setPage(1);
|
||||
}, [setPage]);
|
||||
|
||||
// Handle recipe selection
|
||||
const toggleRecipeSelection = (recipeId: string) => {
|
||||
const newSelection = new Set(selectedRecipes);
|
||||
if (newSelection.has(recipeId)) {
|
||||
newSelection.delete(recipeId);
|
||||
} else {
|
||||
newSelection.add(recipeId);
|
||||
}
|
||||
setSelectedRecipes(newSelection);
|
||||
};
|
||||
|
||||
// Handle recipe actions
|
||||
const handleViewRecipe = (recipe: Recipe) => {
|
||||
// TODO: Navigate to recipe details page or open modal
|
||||
console.log('View recipe:', recipe);
|
||||
};
|
||||
|
||||
const handleEditRecipe = (recipe: Recipe) => {
|
||||
// TODO: Navigate to recipe edit page or open modal
|
||||
console.log('Edit recipe:', recipe);
|
||||
};
|
||||
|
||||
const handleDuplicateRecipe = async (recipe: Recipe) => {
|
||||
const newName = prompt(`Enter name for duplicated recipe:`, `${recipe.name} (Copy)`);
|
||||
if (newName && newName.trim()) {
|
||||
const result = await duplicateRecipe(recipe.id, newName.trim());
|
||||
if (result) {
|
||||
toast.success('Recipe duplicated successfully');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateRecipe = async (recipe: Recipe) => {
|
||||
if (confirm(`Are you sure you want to activate "${recipe.name}"?`)) {
|
||||
const result = await activateRecipe(recipe.id);
|
||||
if (result) {
|
||||
toast.success('Recipe activated successfully');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckFeasibility = async (recipe: Recipe) => {
|
||||
const result = await checkFeasibility(recipe.id, 1.0);
|
||||
if (result) {
|
||||
setFeasibilityResults(prev => new Map(prev.set(recipe.id, result)));
|
||||
if (result.feasible) {
|
||||
toast.success('Recipe can be produced with current inventory');
|
||||
} else {
|
||||
toast.error('Recipe cannot be produced - missing ingredients');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecipe = async (recipe: Recipe) => {
|
||||
if (confirm(`Are you sure you want to delete "${recipe.name}"? This action cannot be undone.`)) {
|
||||
const success = await deleteRecipe(recipe.id);
|
||||
if (success) {
|
||||
toast.success('Recipe deleted successfully');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get quick stats
|
||||
const getQuickStats = () => {
|
||||
if (!statistics) {
|
||||
return {
|
||||
totalRecipes: recipes.length,
|
||||
activeRecipes: recipes.filter(r => r.status === 'active').length,
|
||||
signatureRecipes: recipes.filter(r => r.is_signature_item).length,
|
||||
seasonalRecipes: recipes.filter(r => r.is_seasonal).length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalRecipes: statistics.total_recipes,
|
||||
activeRecipes: statistics.active_recipes,
|
||||
signatureRecipes: statistics.signature_recipes,
|
||||
seasonalRecipes: statistics.seasonal_recipes
|
||||
};
|
||||
};
|
||||
|
||||
const stats = getQuickStats();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Recipe Management</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Create and manage your bakery recipes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
disabled={isLoading}
|
||||
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Recipe</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<ChefHat className="w-8 h-8 text-blue-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Total Recipes</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalRecipes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Active Recipes</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.activeRecipes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<Star className="w-8 h-8 text-yellow-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Signature Items</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.signatureRecipes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-8 h-8 text-purple-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Seasonal Items</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.seasonalRecipes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-lg border mb-6 p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search recipes..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
|
||||
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Filters</span>
|
||||
</button>
|
||||
|
||||
<div className="flex rounded-lg border">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="testing">Testing</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="discontinued">Discontinued</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={filters.category || ''}
|
||||
onChange={(e) => handleFilterChange('category', e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Difficulty
|
||||
</label>
|
||||
<select
|
||||
value={filters.difficulty_level || ''}
|
||||
onChange={(e) => handleFilterChange('difficulty_level',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="1">Level 1 (Easy)</option>
|
||||
<option value="2">Level 2</option>
|
||||
<option value="3">Level 3</option>
|
||||
<option value="4">Level 4</option>
|
||||
<option value="5">Level 5 (Hard)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Special Types */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Special Types
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.is_signature || false}
|
||||
onChange={(e) => handleFilterChange('is_signature', e.target.checked || undefined)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Signature items</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.is_seasonal || false}
|
||||
onChange={(e) => handleFilterChange('is_seasonal', e.target.checked || undefined)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Seasonal items</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipes Grid/List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Loading recipes...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-red-900 mb-2">Error loading recipes</h3>
|
||||
<p className="text-red-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : recipes.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<ChefHat className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{Object.values(filters).some(v => v)
|
||||
? 'No recipes found'
|
||||
: 'No recipes yet'
|
||||
}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{Object.values(filters).some(v => v)
|
||||
? 'Try adjusting your search and filter criteria'
|
||||
: 'Create your first recipe to get started'
|
||||
}
|
||||
</p>
|
||||
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Create Recipe
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{recipes.map((recipe) => (
|
||||
<RecipeCard
|
||||
key={recipe.id}
|
||||
recipe={recipe}
|
||||
compact={viewMode === 'list'}
|
||||
onView={handleViewRecipe}
|
||||
onEdit={handleEditRecipe}
|
||||
onDuplicate={handleDuplicateRecipe}
|
||||
onActivate={handleActivateRecipe}
|
||||
onCheckFeasibility={handleCheckFeasibility}
|
||||
feasibility={feasibilityResults.get(recipe.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {((pagination.page - 1) * pagination.limit) + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of{' '}
|
||||
{pagination.total} recipes
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setPage(page)}
|
||||
className={`px-3 py-2 rounded-lg ${
|
||||
page === pagination.page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipesPage;
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
|
||||
import { SalesAnalyticsDashboard, SalesManagementPage } from '../../components/sales';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
type SalesPageView = 'overview' | 'analytics' | 'management';
|
||||
|
||||
interface SalesPageProps {
|
||||
view?: string;
|
||||
}
|
||||
|
||||
const SalesPage: React.FC<SalesPageProps> = ({ view = 'daily-sales' }) => {
|
||||
const [activeView, setActiveView] = useState<SalesPageView>('overview');
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeView) {
|
||||
case 'analytics':
|
||||
return <SalesAnalyticsDashboard />;
|
||||
case 'management':
|
||||
return <SalesManagementPage />;
|
||||
case 'overview':
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-xl p-8 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Panel de Ventas</h1>
|
||||
<p className="text-blue-100">
|
||||
Gestiona, analiza y optimiza tus ventas con insights inteligentes
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
||||
<ShoppingCart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div
|
||||
onClick={() => setActiveView('analytics')}
|
||||
className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
|
||||
<BarChart3 className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Análisis de Ventas
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Explora métricas detalladas, tendencias y insights de rendimiento
|
||||
</p>
|
||||
<div className="flex items-center text-blue-600 text-sm font-medium">
|
||||
Ver Analytics
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveView('management')}
|
||||
className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
|
||||
<Package className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<ShoppingCart className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Gestión de Ventas
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Administra, filtra y exporta todos tus registros de ventas
|
||||
</p>
|
||||
<div className="flex items-center text-green-600 text-sm font-medium">
|
||||
Gestionar Ventas
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
|
||||
<TrendingUp className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded-full">
|
||||
Próximamente
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Predicciones IA
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Predicciones inteligentes y recomendaciones de ventas
|
||||
</p>
|
||||
<div className="flex items-center text-purple-600 text-sm font-medium opacity-50">
|
||||
En Desarrollo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Insights */}
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Insights Rápidos</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">+12.5%</div>
|
||||
<div className="text-sm text-gray-600">Crecimiento mensual</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<ShoppingCart className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">247</div>
|
||||
<div className="text-sm text-gray-600">Pedidos este mes</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Package className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">18.50€</div>
|
||||
<div className="text-sm text-gray-600">Valor promedio pedido</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<BarChart3 className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">4.2</div>
|
||||
<div className="text-sm text-gray-600">Puntuación satisfacción</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Getting Started */}
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl p-6 border border-indigo-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">¿Primera vez aquí?</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Comienza explorando tus análisis de ventas para descubrir insights valiosos
|
||||
sobre el rendimiento de tu panadería.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
onClick={() => setActiveView('analytics')}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Ver Analytics
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveView('management')}
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Gestionar Ventas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Navigation */}
|
||||
{activeView !== 'overview' && (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<nav className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setActiveView('overview')}
|
||||
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
|
||||
>
|
||||
← Volver al Panel
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesPage;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user