Add new Frontend
This commit is contained in:
68
frontend/src/components/ErrorBoundary.tsx
Normal file
68
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// src/components/ErrorBoundary.tsx
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="bg-white rounded-2xl p-8 shadow-strong">
|
||||
<div className="mx-auto h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-4">
|
||||
¡Oops! Algo salió mal
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full bg-primary-500 text-white py-3 px-4 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-4 text-left">
|
||||
<summary className="text-sm text-gray-500 cursor-pointer">
|
||||
Detalles del error
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
|
||||
{this.state.error?.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,33 +0,0 @@
|
||||
// src/components/auth/ProtectedRoute.tsx
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAuth?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requireAuth = true,
|
||||
redirectTo = '/login'
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuth && !isAuthenticated) {
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
// ForecastChart.tsx (Modified)
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface ForecastData {
|
||||
date: string;
|
||||
predicted_quantity: number;
|
||||
confidence_lower: number;
|
||||
confidence_upper: number;
|
||||
actual_quantity?: number;
|
||||
}
|
||||
|
||||
interface ForecastChartProps {
|
||||
data: ForecastData[];
|
||||
productName: string;
|
||||
// height?: number; // Removed fixed height prop
|
||||
}
|
||||
|
||||
const ForecastChart: React.FC<ForecastChartProps> = ({ data, productName /*, height = 400*/ }) => { // Removed height from props
|
||||
const chartData = {
|
||||
labels: data.map(d => format(new Date(d.date), 'dd MMM', { locale: es })),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Predicción',
|
||||
data: data.map(d => d.predicted_quantity),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Intervalo Inferior',
|
||||
data: data.map(d => d.confidence_lower),
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
},
|
||||
{
|
||||
label: 'Intervalo Superior',
|
||||
data: data.map(d => d.confidence_upper),
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
},
|
||||
// Optional: Actual quantity if available
|
||||
...(data[0]?.actual_quantity !== undefined && data.some(d => d.actual_quantity !== undefined) ? [{
|
||||
label: 'Real',
|
||||
data: data.map(d => d.actual_quantity),
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
hidden: true, // Initially hidden, can be toggled
|
||||
}] : []),
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false, // Ensures the chart fills its parent container's dimensions
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: `Predicción de Demanda - ${productName}`,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
padding: {
|
||||
bottom: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
if (value !== null && value !== undefined) {
|
||||
return `${label}: ${Math.round(value)} unidades`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Fecha',
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cantidad (unidades)',
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest' as const,
|
||||
axis: 'x' as const,
|
||||
intersect: false,
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={chartOptions} />;
|
||||
};
|
||||
|
||||
export default ForecastChart;
|
||||
@@ -1,57 +0,0 @@
|
||||
// src/components/common/ErrorBoundary.tsx
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
|
||||
// Send error to monitoring service
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Algo salió mal
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// src/components/common/NotificationToast.tsx
|
||||
import React, { useEffect } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
interface NotificationToastProps {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const NotificationToast: React.FC<NotificationToastProps> = ({
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
onClose
|
||||
}) => {
|
||||
const icons = {
|
||||
success: CheckCircleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
warning: ExclamationTriangleIcon,
|
||||
info: InformationCircleIcon
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'text-green-400',
|
||||
error: 'text-red-400',
|
||||
warning: 'text-yellow-400',
|
||||
info: 'text-blue-400'
|
||||
};
|
||||
|
||||
const Icon = icons[type];
|
||||
|
||||
return (
|
||||
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className={`h-6 w-6 ${colors[type]}`} />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-gray-900">{title}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface ProductSelectorProps {
|
||||
products: Product[];
|
||||
selected: Product;
|
||||
onChange: (product: Product) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const ProductSelector: React.FC<ProductSelectorProps> = ({
|
||||
products,
|
||||
selected,
|
||||
onChange,
|
||||
label = 'Seleccionar Producto',
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Listbox value={selected} onChange={onChange}>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-orange-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
|
||||
<span className="flex items-center">
|
||||
{selected.icon && (
|
||||
<span className="mr-2 text-lg">{selected.icon}</span>
|
||||
)}
|
||||
<span className="block truncate">{selected.displayName}</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{products.map((product) => (
|
||||
<Listbox.Option
|
||||
key={product.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-orange-100 text-orange-900' : 'text-gray-900'
|
||||
}`
|
||||
}
|
||||
value={product}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex items-center">
|
||||
{product.icon && (
|
||||
<span className="mr-2 text-lg">{product.icon}</span>
|
||||
)}
|
||||
<span
|
||||
className={`block truncate ${
|
||||
selected ? 'font-medium' : 'font-normal'
|
||||
}`}
|
||||
>
|
||||
{product.displayName}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-orange-600">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export default products list
|
||||
export const defaultProducts: Product[] = [
|
||||
{ id: 'pan', name: 'pan', displayName: 'Pan', icon: '🍞' },
|
||||
{ id: 'croissant', name: 'croissant', displayName: 'Croissant', icon: '🥐' },
|
||||
{ id: 'napolitana', name: 'napolitana', displayName: 'Napolitana', icon: '🥮' },
|
||||
{ id: 'palmera', name: 'palmera', displayName: 'Palmera', icon: '🍪' },
|
||||
{ id: 'cafe', name: 'cafe', displayName: 'Café', icon: '☕' },
|
||||
{ id: 'bocadillo', name: 'bocadillo', displayName: 'Bocadillo', icon: '🥖' },
|
||||
{ id: 'tarta', name: 'tarta', displayName: 'Tarta', icon: '🎂' },
|
||||
{ id: 'donut', name: 'donut', displayName: 'Donut', icon: '🍩' },
|
||||
];
|
||||
|
||||
export default ProductSelector;
|
||||
@@ -1,47 +0,0 @@
|
||||
// src/components/data/SalesUploader.tsx
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface SalesUploaderProps {
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SalesUploader: React.FC<SalesUploaderProps> = ({ onUpload }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CloudArrowUpIcon className="h-5 w-5 mr-2" />
|
||||
{isUploading ? 'Uploading...' : 'Upload Sales Data'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
215
frontend/src/components/layout/Layout.tsx
Normal file
215
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Home,
|
||||
TrendingUp,
|
||||
Package,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
User,
|
||||
Bell,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
user: any;
|
||||
currentPage: string;
|
||||
onNavigate: (page: string) => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
children,
|
||||
user,
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onLogout
|
||||
}) => {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
{ id: 'dashboard', label: 'Panel Principal', icon: Home, href: '/dashboard' },
|
||||
{ id: 'forecast', label: 'Predicciones', icon: TrendingUp, href: '/forecast' },
|
||||
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
|
||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
const handleNavigate = (pageId: string) => {
|
||||
onNavigate(pageId);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Top Navigation Bar */}
|
||||
<nav className="bg-white shadow-soft border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
{/* Left side - Logo and Navigation */}
|
||||
<div className="flex items-center">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center ml-4 md:ml-0">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">PanIA</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex md:ml-10 md:space-x-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
className={`
|
||||
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-primary-100 text-primary-700 shadow-soft'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Notifications and User Menu */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-full flex items-center justify-center mr-2">
|
||||
<User className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="hidden md:block text-gray-700 font-medium">
|
||||
{user.fullName?.split(' ')[0] || 'Usuario'}
|
||||
</span>
|
||||
<ChevronDown className="hidden md:block h-4 w-4 ml-1 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-strong border border-gray-200 py-1 z-50">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleNavigate('settings');
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Configuración
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
className={`
|
||||
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Click outside handler for dropdowns */}
|
||||
{(isUserMenuOpen || isMobileMenuOpen) && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,113 +0,0 @@
|
||||
// src/components/training/TrainingProgressCard.tsx
|
||||
import React from 'react';
|
||||
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress';
|
||||
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TrainingProgressCardProps {
|
||||
jobId: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export const TrainingProgressCard: React.FC<TrainingProgressCardProps> = ({
|
||||
jobId,
|
||||
onComplete
|
||||
}) => {
|
||||
const { progress, error, isComplete, isConnected } = useTrainingProgress(jobId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isComplete && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, [isComplete, onComplete]);
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-2 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Training Progress</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isConnected && (
|
||||
<span className="flex items-center text-sm text-green-600">
|
||||
<span className="w-2 h-2 bg-green-600 rounded-full mr-1 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
{progress.status === 'completed' && (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
{progress.status === 'failed' && (
|
||||
<XCircleIcon className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>{progress.current_step}</span>
|
||||
<span>{Math.round(progress.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-indigo-600 h-2 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress.estimated_time_remaining && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Tiempo estimado: {formatTime(progress.estimated_time_remaining)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{progress.metrics && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
{Object.entries(progress.metrics).map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<span className="text-gray-600">{formatMetricName(key)}:</span>
|
||||
<span className="ml-2 font-medium">{formatMetricValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
};
|
||||
|
||||
const formatMetricName = (name: string): string => {
|
||||
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
const formatMetricValue = (value: any): string => {
|
||||
if (typeof value === 'number') {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
57
frontend/src/components/ui/Button.tsx
Normal file
57
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/components/ui/Button.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500 shadow-soft hover:shadow-medium',
|
||||
secondary: 'bg-gray-500 text-white hover:bg-gray-600 focus:ring-gray-500 shadow-soft hover:shadow-medium',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500 shadow-soft hover:shadow-medium',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
34
frontend/src/components/ui/Card.tsx
Normal file
34
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/components/ui/Card.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className,
|
||||
padding = 'md'
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'bg-white rounded-xl shadow-soft',
|
||||
paddingClasses[padding],
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
54
frontend/src/components/ui/Input.tsx
Normal file
54
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/components/ui/Input.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-3 border rounded-xl transition-all duration-200',
|
||||
'placeholder-gray-400 text-gray-900',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||
error
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
27
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
27
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// src/components/ui/LoadingSpinner.tsx
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
className={`animate-spin text-primary-500 ${sizeClasses[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
Reference in New Issue
Block a user