Add new Frontend

This commit is contained in:
Urtzi Alfaro
2025-08-03 19:23:20 +02:00
parent 03e9dc6469
commit 376ce3ee0d
45 changed files with 5352 additions and 9230 deletions

View 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;

View File

@@ -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}</>;
};

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
</>
);
};

View 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;

View File

@@ -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);
};

View 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;

View 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;

View 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;

View 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;