Improve ondobarding steps
This commit is contained in:
@@ -4,7 +4,7 @@ import type { OnboardingStepProps } from './OnboardingWizard';
|
|||||||
|
|
||||||
interface CompanyInfo {
|
interface CompanyInfo {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'artisan' | 'industrial' | 'chain' | 'mixed';
|
type: 'artisan' | 'dependent';
|
||||||
size: 'small' | 'medium' | 'large';
|
size: 'small' | 'medium' | 'large';
|
||||||
locations: number;
|
locations: number;
|
||||||
specialties: string[];
|
specialties: string[];
|
||||||
@@ -25,10 +25,8 @@ interface CompanyInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BAKERY_TYPES = [
|
const BAKERY_TYPES = [
|
||||||
{ value: 'artisan', label: 'Artesanal', description: 'Producción tradicional y manual' },
|
{ value: 'artisan', label: 'Panadería Artesanal Local', description: 'Producción propia y tradicional en el local' },
|
||||||
{ value: 'industrial', label: 'Industrial', description: 'Producción automatizada a gran escala' },
|
{ value: 'dependent', label: 'Panadería Dependiente', description: 'Dependiente de un panadero central' },
|
||||||
{ value: 'chain', label: 'Cadena', description: 'Múltiples ubicaciones con procesos estandarizados' },
|
|
||||||
{ value: 'mixed', label: 'Mixta', description: 'Combinación de métodos artesanales e industriales' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const BAKERY_SIZES = [
|
const BAKERY_SIZES = [
|
||||||
|
|||||||
@@ -1,414 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { default as AlertsPage } from './AlertsPage';
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as NotificationsPage } from './NotificationsPage';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,5 @@
|
|||||||
export { default as ProfilePage } from './profile';
|
export { default as ProfilePage } from './profile';
|
||||||
export { default as BakeryConfigPage } from './bakery-config';
|
export { default as BakeryConfigPage } from './bakery-config';
|
||||||
export { default as TeamPage } from './team';
|
export { default as TeamPage } from './team';
|
||||||
export { default as SystemSettingsPage } from './system';
|
export { default as SubscriptionPage } from './subscription';
|
||||||
export { default as TrainingPage } from './training';
|
export { default as PreferencesPage } from './preferences';
|
||||||
export { default as SubscriptionPage } from './subscription';
|
|
||||||
388
frontend/src/pages/app/settings/preferences/PreferencesPage.tsx
Normal file
388
frontend/src/pages/app/settings/preferences/PreferencesPage.tsx
Normal file
@@ -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;
|
||||||
1
frontend/src/pages/app/settings/preferences/index.ts
Normal file
1
frontend/src/pages/app/settings/preferences/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as PreferencesPage } from './PreferencesPage';
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { default as SystemSettingsPage } from './SystemSettingsPage';
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { default as TrainingPage } from './TrainingPage';
|
|
||||||
@@ -25,16 +25,12 @@ const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insigh
|
|||||||
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
|
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
|
||||||
|
|
||||||
// Communications pages
|
// Communications pages
|
||||||
const AlertsPage = React.lazy(() => import('../pages/app/communications/alerts/AlertsPage'));
|
|
||||||
const NotificationsPage = React.lazy(() => import('../pages/app/communications/notifications/NotificationsPage'));
|
|
||||||
const PreferencesPage = React.lazy(() => import('../pages/app/communications/preferences/PreferencesPage'));
|
const PreferencesPage = React.lazy(() => import('../pages/app/communications/preferences/PreferencesPage'));
|
||||||
|
|
||||||
// Settings pages
|
// Settings pages
|
||||||
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
||||||
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
||||||
const SystemSettingsPage = React.lazy(() => import('../pages/app/settings/system/SystemSettingsPage'));
|
|
||||||
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
||||||
const TrainingPage = React.lazy(() => import('../pages/app/settings/training/TrainingPage'));
|
|
||||||
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
|
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
|
||||||
|
|
||||||
// Data pages
|
// Data pages
|
||||||
@@ -184,26 +180,6 @@ export const AppRouter: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Communications Routes */}
|
{/* Communications Routes */}
|
||||||
<Route
|
|
||||||
path="/app/communications/alerts"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppShell>
|
|
||||||
<AlertsPage />
|
|
||||||
</AppShell>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/app/communications/notifications"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppShell>
|
|
||||||
<NotificationsPage />
|
|
||||||
</AppShell>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/app/communications/preferences"
|
path="/app/communications/preferences"
|
||||||
element={
|
element={
|
||||||
@@ -214,6 +190,18 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Settings Routes */}
|
||||||
|
<Route
|
||||||
|
path="/app/settings/preferences"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<PreferencesPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Settings Routes */}
|
{/* Settings Routes */}
|
||||||
<Route
|
<Route
|
||||||
@@ -236,16 +224,6 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/app/settings/system"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppShell>
|
|
||||||
<SystemSettingsPage />
|
|
||||||
</AppShell>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/app/settings/team"
|
path="/app/settings/team"
|
||||||
element={
|
element={
|
||||||
@@ -256,16 +234,6 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/app/settings/training"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppShell>
|
|
||||||
<TrainingPage />
|
|
||||||
</AppShell>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/app/settings/subscription"
|
path="/app/settings/subscription"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -378,28 +378,6 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/app/settings/system',
|
|
||||||
name: 'SystemSettings',
|
|
||||||
component: 'SystemSettingsPage',
|
|
||||||
title: 'Configuración del Sistema',
|
|
||||||
icon: 'settings',
|
|
||||||
requiresAuth: true,
|
|
||||||
requiredRoles: ['admin'],
|
|
||||||
showInNavigation: true,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/app/settings/training',
|
|
||||||
name: 'Training',
|
|
||||||
component: 'TrainingPage',
|
|
||||||
title: 'Entrenamiento',
|
|
||||||
icon: 'training',
|
|
||||||
requiresAuth: true,
|
|
||||||
requiredRoles: ['admin', 'manager'],
|
|
||||||
showInNavigation: true,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/app/settings/subscription',
|
path: '/app/settings/subscription',
|
||||||
name: 'Subscription',
|
name: 'Subscription',
|
||||||
@@ -411,10 +389,20 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/settings/preferences',
|
||||||
|
name: 'CommunicationPreferences',
|
||||||
|
component: 'PreferencesPage',
|
||||||
|
title: 'Preferencias de Comunicación',
|
||||||
|
icon: 'settings',
|
||||||
|
requiresAuth: true,
|
||||||
|
showInNavigation: true,
|
||||||
|
showInBreadcrumbs: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Communications Section
|
// Communications Section - Keep only for backwards compatibility
|
||||||
{
|
{
|
||||||
path: '/app/communications',
|
path: '/app/communications',
|
||||||
name: 'Communications',
|
name: 'Communications',
|
||||||
@@ -422,28 +410,8 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
title: 'Comunicaciones',
|
title: 'Comunicaciones',
|
||||||
icon: 'notifications',
|
icon: 'notifications',
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
showInNavigation: true,
|
showInNavigation: false,
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
path: '/app/communications/alerts',
|
|
||||||
name: 'Alerts',
|
|
||||||
component: 'AlertsPage',
|
|
||||||
title: 'Alertas',
|
|
||||||
icon: 'notifications',
|
|
||||||
requiresAuth: true,
|
|
||||||
showInNavigation: true,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/app/communications/notifications',
|
|
||||||
name: 'Notifications',
|
|
||||||
component: 'NotificationsPage',
|
|
||||||
title: 'Notificaciones',
|
|
||||||
icon: 'notifications',
|
|
||||||
requiresAuth: true,
|
|
||||||
showInNavigation: true,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/app/communications/preferences',
|
path: '/app/communications/preferences',
|
||||||
name: 'Preferences',
|
name: 'Preferences',
|
||||||
@@ -451,7 +419,7 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
title: 'Preferencias',
|
title: 'Preferencias',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
showInNavigation: true,
|
showInNavigation: false,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export * from './inventory.service';
|
|||||||
export * from './production.service';
|
export * from './production.service';
|
||||||
export * from './sales.service';
|
export * from './sales.service';
|
||||||
export * from './forecasting.service';
|
export * from './forecasting.service';
|
||||||
|
export * from './training.service';
|
||||||
export * from './orders.service';
|
export * from './orders.service';
|
||||||
export * from './procurement.service';
|
export * from './procurement.service';
|
||||||
export * from './pos.service';
|
export * from './pos.service';
|
||||||
export * from './data.service';
|
export * from './data.service';
|
||||||
export * from './training.service';
|
|
||||||
export * from './notification.service';
|
export * from './notification.service';
|
||||||
export * from './subscription.service';
|
export * from './subscription.service';
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ export { ordersService } from './orders.service';
|
|||||||
export { procurementService } from './procurement.service';
|
export { procurementService } from './procurement.service';
|
||||||
export { posService } from './pos.service';
|
export { posService } from './pos.service';
|
||||||
export { dataService } from './data.service';
|
export { dataService } from './data.service';
|
||||||
export { trainingService } from './training.service';
|
|
||||||
export { notificationService } from './notification.service';
|
export { notificationService } from './notification.service';
|
||||||
export { subscriptionService } from './subscription.service';
|
export { subscriptionService } from './subscription.service';
|
||||||
|
|
||||||
|
|||||||
@@ -1,447 +1,70 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
/**
|
||||||
|
* Training service for ML model training operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiClient } from './client';
|
||||||
|
import { ApiResponse } from '../../types/api.types';
|
||||||
|
|
||||||
// Model and training types
|
|
||||||
export interface TrainingJob {
|
export interface TrainingJob {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
model_id: string;
|
||||||
name: string;
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||||
model_type: 'demand_forecasting' | 'sales_prediction' | 'inventory_optimization' | 'production_planning';
|
|
||||||
status: 'pending' | 'initializing' | 'training' | 'validating' | 'completed' | 'failed' | 'cancelled';
|
|
||||||
progress: number;
|
progress: number;
|
||||||
parameters: {
|
|
||||||
data_start_date: string;
|
|
||||||
data_end_date: string;
|
|
||||||
validation_split: number;
|
|
||||||
hyperparameters: Record<string, any>;
|
|
||||||
};
|
|
||||||
metrics?: {
|
|
||||||
accuracy: number;
|
|
||||||
mse: number;
|
|
||||||
mae: number;
|
|
||||||
r2_score: number;
|
|
||||||
validation_accuracy: number;
|
|
||||||
};
|
|
||||||
training_duration_seconds?: number;
|
|
||||||
model_size_mb?: number;
|
|
||||||
created_at: string;
|
|
||||||
started_at?: string;
|
started_at?: string;
|
||||||
completed_at?: string;
|
completed_at?: string;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
created_by: string;
|
parameters: Record<string, any>;
|
||||||
|
metrics?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface TrainingJobCreate {
|
||||||
id: string;
|
model_id: string;
|
||||||
tenant_id: string;
|
parameters?: Record<string, any>;
|
||||||
name: string;
|
|
||||||
model_type: string;
|
|
||||||
version: string;
|
|
||||||
status: 'training' | 'active' | 'deprecated' | 'archived';
|
|
||||||
is_production: boolean;
|
|
||||||
performance_metrics: {
|
|
||||||
accuracy: number;
|
|
||||||
precision: number;
|
|
||||||
recall: number;
|
|
||||||
f1_score: number;
|
|
||||||
last_evaluated: string;
|
|
||||||
};
|
|
||||||
training_data_info: {
|
|
||||||
record_count: number;
|
|
||||||
date_range: {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
};
|
|
||||||
features_used: string[];
|
|
||||||
};
|
|
||||||
deployment_info?: {
|
|
||||||
deployed_at: string;
|
|
||||||
prediction_count: number;
|
|
||||||
avg_response_time_ms: number;
|
|
||||||
};
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainingConfiguration {
|
export interface TrainingJobUpdate {
|
||||||
id: string;
|
parameters?: Record<string, any>;
|
||||||
tenant_id: string;
|
|
||||||
model_type: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
is_default: boolean;
|
|
||||||
parameters: {
|
|
||||||
algorithm: string;
|
|
||||||
hyperparameters: Record<string, any>;
|
|
||||||
feature_selection: string[];
|
|
||||||
validation_method: string;
|
|
||||||
cross_validation_folds?: number;
|
|
||||||
};
|
|
||||||
data_requirements: {
|
|
||||||
minimum_records: number;
|
|
||||||
required_columns: string[];
|
|
||||||
date_range_days: number;
|
|
||||||
};
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrainingService {
|
export class TrainingService extends ApiClient {
|
||||||
private readonly baseUrl = '/training';
|
constructor() {
|
||||||
|
super('/ml/training');
|
||||||
// Training job management
|
|
||||||
async getTrainingJobs(params?: {
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
status?: string;
|
|
||||||
model_type?: string;
|
|
||||||
}): Promise<ApiResponse<{ items: TrainingJob[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
queryParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = queryParams.toString()
|
|
||||||
? `${this.baseUrl}/jobs?${queryParams.toString()}`
|
|
||||||
: `${this.baseUrl}/jobs`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingJob(jobId: string): Promise<ApiResponse<TrainingJob>> {
|
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
|
||||||
return apiClient.get(`${this.baseUrl}/jobs/${jobId}`);
|
const params = modelId ? { model_id: modelId } : {};
|
||||||
|
return this.get('/', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTrainingJob(jobData: {
|
async getTrainingJob(id: string): Promise<ApiResponse<TrainingJob>> {
|
||||||
name: string;
|
return this.get(`/${id}`);
|
||||||
model_type: string;
|
|
||||||
config_id?: string;
|
|
||||||
parameters: {
|
|
||||||
data_start_date: string;
|
|
||||||
data_end_date: string;
|
|
||||||
validation_split?: number;
|
|
||||||
hyperparameters?: Record<string, any>;
|
|
||||||
};
|
|
||||||
}): Promise<ApiResponse<TrainingJob>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/jobs`, jobData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelTrainingJob(jobId: string): Promise<ApiResponse<{ message: string }>> {
|
async createTrainingJob(data: TrainingJobCreate): Promise<ApiResponse<TrainingJob>> {
|
||||||
return apiClient.post(`${this.baseUrl}/jobs/${jobId}/cancel`);
|
return this.post('/', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async retryTrainingJob(jobId: string): Promise<ApiResponse<TrainingJob>> {
|
async updateTrainingJob(id: string, data: TrainingJobUpdate): Promise<ApiResponse<TrainingJob>> {
|
||||||
return apiClient.post(`${this.baseUrl}/jobs/${jobId}/retry`);
|
return this.put(`/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingLogs(jobId: string, params?: {
|
async deleteTrainingJob(id: string): Promise<ApiResponse<void>> {
|
||||||
level?: 'debug' | 'info' | 'warning' | 'error';
|
return this.delete(`/${id}`);
|
||||||
limit?: number;
|
|
||||||
}): Promise<ApiResponse<Array<{
|
|
||||||
timestamp: string;
|
|
||||||
level: string;
|
|
||||||
message: string;
|
|
||||||
details?: Record<string, any>;
|
|
||||||
}>>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
queryParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = queryParams.toString()
|
|
||||||
? `${this.baseUrl}/jobs/${jobId}/logs?${queryParams.toString()}`
|
|
||||||
: `${this.baseUrl}/jobs/${jobId}/logs`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model management
|
async startTraining(id: string): Promise<ApiResponse<TrainingJob>> {
|
||||||
async getModels(params?: {
|
return this.post(`/${id}/start`);
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
model_type?: string;
|
|
||||||
status?: string;
|
|
||||||
is_production?: boolean;
|
|
||||||
}): Promise<ApiResponse<{ items: ModelInfo[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
queryParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = queryParams.toString()
|
|
||||||
? `${this.baseUrl}/models?${queryParams.toString()}`
|
|
||||||
: `${this.baseUrl}/models`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getModel(modelId: string): Promise<ApiResponse<ModelInfo>> {
|
async stopTraining(id: string): Promise<ApiResponse<TrainingJob>> {
|
||||||
return apiClient.get(`${this.baseUrl}/models/${modelId}`);
|
return this.post(`/${id}/stop`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deployModel(modelId: string): Promise<ApiResponse<{ message: string; deployment_id: string }>> {
|
async getTrainingLogs(id: string): Promise<ApiResponse<string[]>> {
|
||||||
return apiClient.post(`${this.baseUrl}/models/${modelId}/deploy`);
|
return this.get(`/${id}/logs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async undeployModel(modelId: string): Promise<ApiResponse<{ message: string }>> {
|
async getTrainingMetrics(id: string): Promise<ApiResponse<Record<string, number>>> {
|
||||||
return apiClient.post(`${this.baseUrl}/models/${modelId}/undeploy`);
|
return this.get(`/${id}/metrics`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
async deleteModel(modelId: string): Promise<ApiResponse<{ message: string }>> {
|
|
||||||
return apiClient.delete(`${this.baseUrl}/models/${modelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async compareModels(modelIds: string[]): Promise<ApiResponse<{
|
|
||||||
models: ModelInfo[];
|
|
||||||
comparison: {
|
|
||||||
accuracy_comparison: Array<{ model_id: string; accuracy: number; rank: number }>;
|
|
||||||
performance_metrics: Record<string, Array<{ model_id: string; value: number }>>;
|
|
||||||
recommendation: {
|
|
||||||
best_model_id: string;
|
|
||||||
reason: string;
|
|
||||||
confidence: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/models/compare`, { model_ids: modelIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model evaluation
|
|
||||||
async evaluateModel(modelId: string, evaluationData?: {
|
|
||||||
test_data_start?: string;
|
|
||||||
test_data_end?: string;
|
|
||||||
metrics?: string[];
|
|
||||||
}): Promise<ApiResponse<{
|
|
||||||
evaluation_id: string;
|
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
}>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/models/${modelId}/evaluate`, evaluationData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEvaluationResults(evaluationId: string): Promise<ApiResponse<{
|
|
||||||
model_id: string;
|
|
||||||
status: string;
|
|
||||||
metrics: Record<string, number>;
|
|
||||||
predictions_sample: Array<{
|
|
||||||
actual: number;
|
|
||||||
predicted: number;
|
|
||||||
date: string;
|
|
||||||
error: number;
|
|
||||||
}>;
|
|
||||||
feature_importance?: Array<{
|
|
||||||
feature: string;
|
|
||||||
importance: number;
|
|
||||||
}>;
|
|
||||||
completed_at: string;
|
|
||||||
}>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/evaluations/${evaluationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Training configuration
|
|
||||||
async getTrainingConfigs(modelType?: string): Promise<ApiResponse<TrainingConfiguration[]>> {
|
|
||||||
const url = modelType
|
|
||||||
? `${this.baseUrl}/configs?model_type=${encodeURIComponent(modelType)}`
|
|
||||||
: `${this.baseUrl}/configs`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTrainingConfig(configId: string): Promise<ApiResponse<TrainingConfiguration>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/configs/${configId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTrainingConfig(configData: {
|
|
||||||
model_type: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
parameters: TrainingConfiguration['parameters'];
|
|
||||||
data_requirements?: Partial<TrainingConfiguration['data_requirements']>;
|
|
||||||
}): Promise<ApiResponse<TrainingConfiguration>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/configs`, configData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateTrainingConfig(configId: string, configData: Partial<TrainingConfiguration>): Promise<ApiResponse<TrainingConfiguration>> {
|
|
||||||
return apiClient.put(`${this.baseUrl}/configs/${configId}`, configData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteTrainingConfig(configId: string): Promise<ApiResponse<{ message: string }>> {
|
|
||||||
return apiClient.delete(`${this.baseUrl}/configs/${configId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data analysis and preparation
|
|
||||||
async analyzeTrainingData(params: {
|
|
||||||
model_type: string;
|
|
||||||
data_start_date: string;
|
|
||||||
data_end_date: string;
|
|
||||||
}): Promise<ApiResponse<{
|
|
||||||
data_quality: {
|
|
||||||
total_records: number;
|
|
||||||
complete_records: number;
|
|
||||||
missing_data_percentage: number;
|
|
||||||
duplicate_records: number;
|
|
||||||
};
|
|
||||||
feature_analysis: Array<{
|
|
||||||
feature: string;
|
|
||||||
data_type: string;
|
|
||||||
completeness: number;
|
|
||||||
unique_values: number;
|
|
||||||
correlation_with_target?: number;
|
|
||||||
}>;
|
|
||||||
recommendations: Array<{
|
|
||||||
type: 'data_cleaning' | 'feature_engineering' | 'model_selection';
|
|
||||||
message: string;
|
|
||||||
priority: 'high' | 'medium' | 'low';
|
|
||||||
}>;
|
|
||||||
training_feasibility: {
|
|
||||||
can_train: boolean;
|
|
||||||
minimum_requirements_met: boolean;
|
|
||||||
estimated_training_time_minutes: number;
|
|
||||||
};
|
|
||||||
}>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/analyze-data`, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDataAlignmentStatus(): Promise<ApiResponse<{
|
|
||||||
status: 'aligned' | 'misaligned' | 'processing';
|
|
||||||
last_alignment: string;
|
|
||||||
data_sources: Array<{
|
|
||||||
source: string;
|
|
||||||
status: 'synced' | 'out_of_sync' | 'error';
|
|
||||||
last_sync: string;
|
|
||||||
record_count: number;
|
|
||||||
}>;
|
|
||||||
issues?: Array<{
|
|
||||||
source: string;
|
|
||||||
issue_type: string;
|
|
||||||
description: string;
|
|
||||||
severity: 'low' | 'medium' | 'high';
|
|
||||||
}>;
|
|
||||||
}>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/data-alignment/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async triggerDataAlignment(): Promise<ApiResponse<{ message: string; task_id: string }>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/data-alignment/trigger`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Training insights and recommendations
|
|
||||||
async getTrainingInsights(): Promise<ApiResponse<{
|
|
||||||
model_performance_trends: Array<{
|
|
||||||
model_type: string;
|
|
||||||
accuracy_trend: 'improving' | 'stable' | 'declining';
|
|
||||||
last_training_date: string;
|
|
||||||
recommendation: string;
|
|
||||||
}>;
|
|
||||||
training_frequency_suggestions: Array<{
|
|
||||||
model_type: string;
|
|
||||||
current_frequency: string;
|
|
||||||
suggested_frequency: string;
|
|
||||||
reason: string;
|
|
||||||
}>;
|
|
||||||
data_quality_alerts: Array<{
|
|
||||||
alert_type: string;
|
|
||||||
severity: 'info' | 'warning' | 'critical';
|
|
||||||
message: string;
|
|
||||||
affected_models: string[];
|
|
||||||
}>;
|
|
||||||
}>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/insights`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility methods
|
|
||||||
getModelTypes(): { value: string; label: string; description: string }[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: 'demand_forecasting',
|
|
||||||
label: 'Demand Forecasting',
|
|
||||||
description: 'Predict future demand for products based on historical sales and external factors'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sales_prediction',
|
|
||||||
label: 'Sales Prediction',
|
|
||||||
description: 'Forecast sales revenue and patterns'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'inventory_optimization',
|
|
||||||
label: 'Inventory Optimization',
|
|
||||||
description: 'Optimize inventory levels and reorder points'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'production_planning',
|
|
||||||
label: 'Production Planning',
|
|
||||||
description: 'Optimize production schedules and capacity planning'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
getAlgorithmOptions(): { value: string; label: string; suitable_for: string[] }[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: 'prophet',
|
|
||||||
label: 'Prophet',
|
|
||||||
suitable_for: ['demand_forecasting', 'sales_prediction']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'arima',
|
|
||||||
label: 'ARIMA',
|
|
||||||
suitable_for: ['demand_forecasting', 'sales_prediction']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'random_forest',
|
|
||||||
label: 'Random Forest',
|
|
||||||
suitable_for: ['inventory_optimization', 'production_planning']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'xgboost',
|
|
||||||
label: 'XGBoost',
|
|
||||||
suitable_for: ['demand_forecasting', 'inventory_optimization']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'lstm',
|
|
||||||
label: 'LSTM Neural Network',
|
|
||||||
suitable_for: ['demand_forecasting', 'sales_prediction']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
getValidationMethods(): { value: string; label: string; description: string }[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: 'time_series_split',
|
|
||||||
label: 'Time Series Split',
|
|
||||||
description: 'Split data chronologically for time series validation'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'k_fold',
|
|
||||||
label: 'K-Fold Cross Validation',
|
|
||||||
description: 'Standard k-fold cross validation'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'stratified_k_fold',
|
|
||||||
label: 'Stratified K-Fold',
|
|
||||||
description: 'Stratified sampling for cross validation'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'holdout',
|
|
||||||
label: 'Holdout Validation',
|
|
||||||
description: 'Simple train/validation split'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const trainingService = new TrainingService();
|
|
||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
|
||||||
export type BakeryType = 'individual' | 'central' | 'hybrid';
|
export type BakeryType = 'artisan' | 'dependent';
|
||||||
export type BusinessModel = 'production' | 'retail' | 'hybrid';
|
export type BusinessModel = 'production' | 'retail' | 'hybrid';
|
||||||
|
|
||||||
interface BakeryState {
|
interface BakeryState {
|
||||||
@@ -40,7 +40,7 @@ interface Tenant {
|
|||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
currentTenant: null,
|
currentTenant: null,
|
||||||
bakeryType: 'individual' as BakeryType,
|
bakeryType: 'artisan' as BakeryType,
|
||||||
businessModel: 'production' as BusinessModel,
|
businessModel: 'production' as BusinessModel,
|
||||||
operatingHours: {
|
operatingHours: {
|
||||||
start: '04:00',
|
start: '04:00',
|
||||||
@@ -70,10 +70,10 @@ export const useBakeryStore = create<BakeryState>()(
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
state.bakeryType = type;
|
state.bakeryType = type;
|
||||||
// Adjust features based on bakery type
|
// Adjust features based on bakery type
|
||||||
if (type === 'individual') {
|
if (type === 'artisan') {
|
||||||
state.features.pos = false;
|
state.features.pos = false;
|
||||||
state.businessModel = 'production';
|
state.businessModel = 'production';
|
||||||
} else if (type === 'central') {
|
} else if (type === 'dependent') {
|
||||||
state.features.pos = true;
|
state.features.pos = true;
|
||||||
state.businessModel = 'retail';
|
state.businessModel = 'retail';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user