Add new page designs
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, forwardRef } from 'react';
|
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||||
@@ -127,53 +127,55 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get navigation routes from config
|
// Get navigation routes from config and convert to navigation items - memoized
|
||||||
const navigationRoutes = getNavigationRoutes();
|
const navigationItems = useMemo(() => {
|
||||||
|
const navigationRoutes = getNavigationRoutes();
|
||||||
// Convert route config to navigation items
|
|
||||||
const convertRoutesToItems = (routes: typeof navigationRoutes): NavigationItem[] => {
|
const convertRoutesToItems = (routes: typeof navigationRoutes): NavigationItem[] => {
|
||||||
return routes.map(route => ({
|
return routes.map(route => ({
|
||||||
id: route.path,
|
id: route.path,
|
||||||
label: route.title,
|
label: route.title,
|
||||||
path: route.path,
|
path: route.path,
|
||||||
icon: route.icon ? iconMap[route.icon] : undefined,
|
icon: route.icon ? iconMap[route.icon] : undefined,
|
||||||
requiredPermissions: route.requiredPermissions,
|
requiredPermissions: route.requiredPermissions,
|
||||||
requiredRoles: route.requiredRoles,
|
requiredRoles: route.requiredRoles,
|
||||||
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigationItems = customItems || convertRoutesToItems(navigationRoutes);
|
return customItems || convertRoutesToItems(navigationRoutes);
|
||||||
|
}, [customItems]);
|
||||||
|
|
||||||
// Filter items based on user permissions
|
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||||
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
|
const visibleItems = useMemo(() => {
|
||||||
if (!isAuthenticated || !user) return [];
|
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
|
||||||
|
if (!isAuthenticated || !user) return [];
|
||||||
|
|
||||||
return items.filter(item => {
|
return items.map(item => ({
|
||||||
const userRoles = user.role ? [user.role] : [];
|
...item, // Create a shallow copy to avoid mutation
|
||||||
const userPermissions: string[] = user?.permissions || [];
|
children: item.children ? filterItemsByPermissions(item.children) : item.children
|
||||||
|
})).filter(item => {
|
||||||
|
const userRoles = user.role ? [user.role] : [];
|
||||||
|
const userPermissions: string[] = user?.permissions || [];
|
||||||
|
|
||||||
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
|
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
|
||||||
canAccessRoute(
|
canAccessRoute(
|
||||||
{
|
{
|
||||||
path: item.path,
|
path: item.path,
|
||||||
requiredRoles: item.requiredRoles,
|
requiredRoles: item.requiredRoles,
|
||||||
requiredPermissions: item.requiredPermissions
|
requiredPermissions: item.requiredPermissions
|
||||||
} as any,
|
} as any,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
userRoles,
|
userRoles,
|
||||||
userPermissions
|
userPermissions
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasAccess && item.children) {
|
return hasAccess;
|
||||||
item.children = filterItemsByPermissions(item.children);
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return hasAccess;
|
return filterItemsByPermissions(navigationItems);
|
||||||
});
|
}, [navigationItems, isAuthenticated, user]);
|
||||||
};
|
|
||||||
|
|
||||||
const visibleItems = filterItemsByPermissions(navigationItems);
|
|
||||||
|
|
||||||
// Handle item click
|
// Handle item click
|
||||||
const handleItemClick = useCallback((item: NavigationItem) => {
|
const handleItemClick = useCallback((item: NavigationItem) => {
|
||||||
@@ -224,30 +226,30 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-expand parent items for active path
|
// Auto-expand parent items for active path
|
||||||
React.useEffect(() => {
|
const findParentPaths = useCallback((items: NavigationItem[], targetPath: string, parents: string[] = []): string[] => {
|
||||||
const findParentPaths = (items: NavigationItem[], targetPath: string, parents: string[] = []): string[] => {
|
for (const item of items) {
|
||||||
for (const item of items) {
|
const currentPath = [...parents, item.id];
|
||||||
const currentPath = [...parents, item.id];
|
|
||||||
|
if (item.path === targetPath) {
|
||||||
if (item.path === targetPath) {
|
return parents;
|
||||||
return parents;
|
}
|
||||||
}
|
|
||||||
|
if (item.children) {
|
||||||
if (item.children) {
|
const found = findParentPaths(item.children, targetPath, currentPath);
|
||||||
const found = findParentPaths(item.children, targetPath, currentPath);
|
if (found.length > 0) {
|
||||||
if (found.length > 0) {
|
return found;
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
}
|
||||||
};
|
return [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
const parentPaths = findParentPaths(visibleItems, location.pathname);
|
const parentPaths = findParentPaths(visibleItems, location.pathname);
|
||||||
if (parentPaths.length > 0) {
|
if (parentPaths.length > 0) {
|
||||||
setExpandedItems(prev => new Set([...prev, ...parentPaths]));
|
setExpandedItems(prev => new Set([...prev, ...parentPaths]));
|
||||||
}
|
}
|
||||||
}, [location.pathname, visibleItems]);
|
}, [location.pathname, findParentPaths, visibleItems]);
|
||||||
|
|
||||||
// Expose ref methods
|
// Expose ref methods
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
|||||||
215
frontend/src/components/ui/Stats/StatsCard.tsx
Normal file
215
frontend/src/components/ui/Stats/StatsCard.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { Card } from '../Card';
|
||||||
|
|
||||||
|
export type StatsCardVariant = 'default' | 'success' | 'info' | 'warning' | 'error' | 'purple';
|
||||||
|
export type StatsCardSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
export interface StatsCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: StatsCardVariant;
|
||||||
|
size?: StatsCardSize;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
direction: 'up' | 'down' | 'neutral';
|
||||||
|
};
|
||||||
|
subtitle?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
formatValue?: (value: string | number) => string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsCard = forwardRef<HTMLDivElement, StatsCardProps>(({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'md',
|
||||||
|
trend,
|
||||||
|
subtitle,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
formatValue,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const formattedValue = formatValue ? formatValue(value) : value;
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: {
|
||||||
|
iconColor: 'var(--text-tertiary)',
|
||||||
|
valueColor: 'var(--text-primary)',
|
||||||
|
iconBg: 'var(--bg-tertiary)',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconColor: 'var(--color-success)',
|
||||||
|
valueColor: 'var(--color-success)',
|
||||||
|
iconBg: 'var(--color-success-50)',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
iconColor: 'var(--color-info)',
|
||||||
|
valueColor: 'var(--color-info)',
|
||||||
|
iconBg: 'var(--color-info-50)',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
iconColor: 'var(--color-warning)',
|
||||||
|
valueColor: 'var(--color-warning)',
|
||||||
|
iconBg: 'var(--color-warning-50)',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconColor: 'var(--color-error)',
|
||||||
|
valueColor: 'var(--color-error)',
|
||||||
|
iconBg: 'var(--color-error-50)',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
iconColor: '#a78bfa',
|
||||||
|
valueColor: '#a78bfa',
|
||||||
|
iconBg: 'rgba(167, 139, 250, 0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: {
|
||||||
|
padding: 'p-4',
|
||||||
|
iconSize: 20,
|
||||||
|
valueSize: 'text-2xl',
|
||||||
|
titleSize: 'text-sm',
|
||||||
|
iconPadding: 'p-2',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
padding: 'p-6',
|
||||||
|
iconSize: 24,
|
||||||
|
valueSize: 'text-3xl',
|
||||||
|
titleSize: 'text-sm',
|
||||||
|
iconPadding: 'p-2.5',
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
padding: 'p-8',
|
||||||
|
iconSize: 28,
|
||||||
|
valueSize: 'text-4xl',
|
||||||
|
titleSize: 'text-base',
|
||||||
|
iconPadding: 'p-3',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentVariant = variantStyles[variant];
|
||||||
|
const currentSize = sizeStyles[size];
|
||||||
|
const isInteractive = !!onClick;
|
||||||
|
|
||||||
|
const cardClasses = clsx(
|
||||||
|
{
|
||||||
|
'animate-pulse': loading,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconContainerClasses = clsx(
|
||||||
|
'rounded-xl flex items-center justify-center',
|
||||||
|
currentSize.iconPadding
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconContainerStyle = {
|
||||||
|
backgroundColor: currentVariant.iconBg,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
padding={size}
|
||||||
|
interactive={isInteractive}
|
||||||
|
className={cardClasses}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||||
|
{subtitle && <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-xl"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
padding={size}
|
||||||
|
interactive={isInteractive}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cardClasses}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3
|
||||||
|
className={clsx('font-medium mb-2 leading-tight', currentSize.titleSize)}
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className={clsx('font-bold leading-none', currentSize.valueSize)}
|
||||||
|
style={{ color: currentVariant.valueColor }}
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{trend && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-xs font-medium px-1.5 py-0.5 rounded-full',
|
||||||
|
{
|
||||||
|
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': trend.direction === 'up',
|
||||||
|
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400': trend.direction === 'down',
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300': trend.direction === 'neutral',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.direction === 'up' && '+'}
|
||||||
|
{trend.value}
|
||||||
|
{trend.label && ` ${trend.label}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subtitle && (
|
||||||
|
<p
|
||||||
|
className="text-xs leading-relaxed"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
className={iconContainerClasses}
|
||||||
|
style={iconContainerStyle}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={currentSize.iconSize}
|
||||||
|
style={{ color: currentVariant.iconColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
StatsCard.displayName = 'StatsCard';
|
||||||
|
|
||||||
|
export default StatsCard;
|
||||||
201
frontend/src/components/ui/Stats/StatsExample.tsx
Normal file
201
frontend/src/components/ui/Stats/StatsExample.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { StatsGrid, StatsCard } from './index';
|
||||||
|
import { pagePresets, businessMetrics, formatters } from './StatsPresets';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
|
Shield
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Example: Production Management Stats (matching your screenshot)
|
||||||
|
export const ProductionStatsExample: FC = () => {
|
||||||
|
// Sample data
|
||||||
|
const productionData = {
|
||||||
|
dailyTarget: 150,
|
||||||
|
completed: 85,
|
||||||
|
inProgress: 12,
|
||||||
|
pending: 53,
|
||||||
|
efficiency: 78,
|
||||||
|
quality: 94,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsGrid
|
||||||
|
stats={pagePresets.production(productionData)}
|
||||||
|
columns={6}
|
||||||
|
title="Gestión de Producción"
|
||||||
|
description="Planifica y controla la producción diaria de tu panadería"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Custom Stats Configuration
|
||||||
|
export const CustomStatsExample = () => {
|
||||||
|
return (
|
||||||
|
<StatsGrid
|
||||||
|
stats={[
|
||||||
|
{
|
||||||
|
title: 'Meta Diaria',
|
||||||
|
value: 150,
|
||||||
|
icon: Calendar,
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Completado',
|
||||||
|
value: 85,
|
||||||
|
icon: CheckCircle,
|
||||||
|
variant: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'En Proceso',
|
||||||
|
value: 12,
|
||||||
|
icon: Clock,
|
||||||
|
variant: 'info',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pendiente',
|
||||||
|
value: 53,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
variant: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Eficiencia',
|
||||||
|
value: 78,
|
||||||
|
icon: Zap,
|
||||||
|
variant: 'purple',
|
||||||
|
formatValue: formatters.percentage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Calidad',
|
||||||
|
value: 94,
|
||||||
|
icon: Shield,
|
||||||
|
variant: 'success',
|
||||||
|
formatValue: formatters.percentage,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={6}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Sales Dashboard
|
||||||
|
export const SalesStatsExample = () => {
|
||||||
|
const salesData = {
|
||||||
|
revenue: 15420,
|
||||||
|
revenueGrowth: 12.5,
|
||||||
|
orders: 142,
|
||||||
|
ordersGrowth: -3.2,
|
||||||
|
customers: 89,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatsGrid
|
||||||
|
stats={pagePresets.sales(salesData)}
|
||||||
|
columns={3}
|
||||||
|
title="Ventas del Día"
|
||||||
|
description="Resumen de rendimiento de ventas"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Individual StatsCard Usage
|
||||||
|
export const IndividualStatsExample = () => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatsCard
|
||||||
|
title="Pedidos Hoy"
|
||||||
|
value={45}
|
||||||
|
icon={CheckCircle}
|
||||||
|
variant="success"
|
||||||
|
trend={{
|
||||||
|
value: 15,
|
||||||
|
direction: 'up',
|
||||||
|
label: '%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
title="Ingresos"
|
||||||
|
value={2840}
|
||||||
|
icon={Calendar}
|
||||||
|
variant="info"
|
||||||
|
formatValue={formatters.currency}
|
||||||
|
subtitle="Últimas 24 horas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
title="Productos Vendidos"
|
||||||
|
value={1250}
|
||||||
|
icon={Zap}
|
||||||
|
variant="default"
|
||||||
|
formatValue={formatters.number}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
title="Satisfacción"
|
||||||
|
value={96}
|
||||||
|
icon={Shield}
|
||||||
|
variant="success"
|
||||||
|
formatValue={formatters.percentage}
|
||||||
|
onClick={() => console.log('Navigate to satisfaction details')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Loading State
|
||||||
|
export const LoadingStatsExample = () => {
|
||||||
|
return (
|
||||||
|
<StatsGrid
|
||||||
|
stats={[
|
||||||
|
{ title: '', value: '' },
|
||||||
|
{ title: '', value: '' },
|
||||||
|
{ title: '', value: '' },
|
||||||
|
{ title: '', value: '' },
|
||||||
|
]}
|
||||||
|
loading
|
||||||
|
columns={4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Using Business Metrics Directly
|
||||||
|
export const BusinessMetricsExample = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<StatsGrid
|
||||||
|
stats={[
|
||||||
|
businessMetrics.production.dailyTarget(150),
|
||||||
|
businessMetrics.production.completed(85),
|
||||||
|
businessMetrics.production.efficiency(78),
|
||||||
|
]}
|
||||||
|
columns={3}
|
||||||
|
title="Producción"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsGrid
|
||||||
|
stats={[
|
||||||
|
businessMetrics.sales.revenue(15420, { value: 12.5, direction: 'up' }),
|
||||||
|
businessMetrics.sales.orders(142, { value: 3.2, direction: 'down' }),
|
||||||
|
businessMetrics.sales.customers(89),
|
||||||
|
]}
|
||||||
|
columns={3}
|
||||||
|
title="Ventas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsGrid
|
||||||
|
stats={[
|
||||||
|
businessMetrics.inventory.totalItems(450),
|
||||||
|
businessMetrics.inventory.lowStock(12),
|
||||||
|
businessMetrics.inventory.outOfStock(3),
|
||||||
|
]}
|
||||||
|
columns={3}
|
||||||
|
title="Inventario"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
frontend/src/components/ui/Stats/StatsGrid.tsx
Normal file
94
frontend/src/components/ui/Stats/StatsGrid.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import StatsCard, { StatsCardProps } from './StatsCard';
|
||||||
|
|
||||||
|
export interface StatsGridProps {
|
||||||
|
stats: StatsCardProps[];
|
||||||
|
columns?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
gap?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsGrid: React.FC<StatsGridProps> = ({
|
||||||
|
stats,
|
||||||
|
columns = 3,
|
||||||
|
gap = 'md',
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}) => {
|
||||||
|
const gridClasses = {
|
||||||
|
1: 'grid-cols-1',
|
||||||
|
2: 'grid-cols-1 sm:grid-cols-2',
|
||||||
|
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||||
|
5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
||||||
|
6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const gapClasses = {
|
||||||
|
sm: 'gap-3',
|
||||||
|
md: 'gap-4',
|
||||||
|
lg: 'gap-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
'grid',
|
||||||
|
gridClasses[columns],
|
||||||
|
gapClasses[gap],
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
{(title || description) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{title && (
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold leading-tight"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className="text-base leading-relaxed"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: stats.length || 6 }).map((_, index) => (
|
||||||
|
<StatsCard
|
||||||
|
key={index}
|
||||||
|
title=""
|
||||||
|
value=""
|
||||||
|
loading
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: stats.map((stat, index) => (
|
||||||
|
<StatsCard
|
||||||
|
key={stat.title || index}
|
||||||
|
{...stat}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsGrid;
|
||||||
237
frontend/src/components/ui/Stats/StatsPresets.ts
Normal file
237
frontend/src/components/ui/Stats/StatsPresets.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
TrendingUp,
|
||||||
|
Package,
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
BarChart3,
|
||||||
|
Target,
|
||||||
|
Activity,
|
||||||
|
Award
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { StatsCardProps, StatsCardVariant } from './StatsCard';
|
||||||
|
|
||||||
|
// Common formatting functions
|
||||||
|
export const formatters = {
|
||||||
|
percentage: (value: string | number): string => `${value}%`,
|
||||||
|
currency: (value: string | number): string => `€${parseFloat(String(value)).toFixed(2)}`,
|
||||||
|
number: (value: string | number): string => parseFloat(String(value)).toLocaleString('es-ES'),
|
||||||
|
compact: (value: string | number): string => {
|
||||||
|
const num = parseFloat(String(value));
|
||||||
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
return num.toString();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Icon mappings for common stat types
|
||||||
|
export const statIcons = {
|
||||||
|
target: Calendar,
|
||||||
|
completed: CheckCircle,
|
||||||
|
inProgress: Clock,
|
||||||
|
pending: AlertTriangle,
|
||||||
|
efficiency: Zap,
|
||||||
|
quality: Shield,
|
||||||
|
growth: TrendingUp,
|
||||||
|
inventory: Package,
|
||||||
|
users: Users,
|
||||||
|
revenue: DollarSign,
|
||||||
|
analytics: BarChart3,
|
||||||
|
goals: Target,
|
||||||
|
activity: Activity,
|
||||||
|
achievement: Award,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Variant mappings for common stat types
|
||||||
|
export const statVariants: Record<string, StatsCardVariant> = {
|
||||||
|
target: 'default',
|
||||||
|
completed: 'success',
|
||||||
|
inProgress: 'info',
|
||||||
|
pending: 'warning',
|
||||||
|
efficiency: 'purple',
|
||||||
|
quality: 'success',
|
||||||
|
error: 'error',
|
||||||
|
revenue: 'success',
|
||||||
|
growth: 'success',
|
||||||
|
decline: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Predefined stat configurations for common business metrics
|
||||||
|
export const businessMetrics = {
|
||||||
|
production: {
|
||||||
|
dailyTarget: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Meta Diaria',
|
||||||
|
value,
|
||||||
|
icon: statIcons.target,
|
||||||
|
variant: statVariants.target,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
completed: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Completado',
|
||||||
|
value,
|
||||||
|
icon: statIcons.completed,
|
||||||
|
variant: statVariants.completed,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
inProgress: (value: number): StatsCardProps => ({
|
||||||
|
title: 'En Proceso',
|
||||||
|
value,
|
||||||
|
icon: statIcons.inProgress,
|
||||||
|
variant: statVariants.inProgress,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
pending: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Pendiente',
|
||||||
|
value,
|
||||||
|
icon: statIcons.pending,
|
||||||
|
variant: statVariants.pending,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
efficiency: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Eficiencia',
|
||||||
|
value,
|
||||||
|
icon: statIcons.efficiency,
|
||||||
|
variant: statVariants.efficiency,
|
||||||
|
formatValue: formatters.percentage,
|
||||||
|
}),
|
||||||
|
quality: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Calidad',
|
||||||
|
value,
|
||||||
|
icon: statIcons.quality,
|
||||||
|
variant: statVariants.quality,
|
||||||
|
formatValue: formatters.percentage,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
sales: {
|
||||||
|
revenue: (value: number, trend?: { value: number; direction: 'up' | 'down' | 'neutral'; label?: string }): StatsCardProps => ({
|
||||||
|
title: 'Ingresos',
|
||||||
|
value,
|
||||||
|
icon: statIcons.revenue,
|
||||||
|
variant: statVariants.revenue,
|
||||||
|
formatValue: formatters.currency,
|
||||||
|
trend,
|
||||||
|
}),
|
||||||
|
orders: (value: number, trend?: { value: number; direction: 'up' | 'down' | 'neutral'; label?: string }): StatsCardProps => ({
|
||||||
|
title: 'Pedidos',
|
||||||
|
value,
|
||||||
|
icon: statIcons.analytics,
|
||||||
|
variant: statVariants.target,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
trend,
|
||||||
|
}),
|
||||||
|
customers: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Clientes',
|
||||||
|
value,
|
||||||
|
icon: statIcons.users,
|
||||||
|
variant: statVariants.target,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
inventory: {
|
||||||
|
totalItems: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Total Items',
|
||||||
|
value,
|
||||||
|
icon: statIcons.inventory,
|
||||||
|
variant: statVariants.target,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
lowStock: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Stock Bajo',
|
||||||
|
value,
|
||||||
|
icon: statIcons.pending,
|
||||||
|
variant: value > 0 ? statVariants.pending : statVariants.completed,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
outOfStock: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Sin Stock',
|
||||||
|
value,
|
||||||
|
icon: statIcons.pending,
|
||||||
|
variant: value > 0 ? statVariants.error : statVariants.completed,
|
||||||
|
formatValue: formatters.number,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
performance: {
|
||||||
|
growth: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Crecimiento',
|
||||||
|
value,
|
||||||
|
icon: statIcons.growth,
|
||||||
|
variant: value >= 0 ? statVariants.growth : statVariants.decline,
|
||||||
|
formatValue: formatters.percentage,
|
||||||
|
trend: {
|
||||||
|
value: Math.abs(value),
|
||||||
|
direction: value >= 0 ? 'up' : 'down',
|
||||||
|
label: 'vs mes anterior',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
satisfaction: (value: number): StatsCardProps => ({
|
||||||
|
title: 'Satisfacción',
|
||||||
|
value,
|
||||||
|
icon: statIcons.achievement,
|
||||||
|
variant: value >= 80 ? statVariants.quality : value >= 60 ? statVariants.pending : statVariants.error,
|
||||||
|
formatValue: formatters.percentage,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick preset configurations for common page layouts
|
||||||
|
export const pagePresets = {
|
||||||
|
production: (data: {
|
||||||
|
dailyTarget: number;
|
||||||
|
completed: number;
|
||||||
|
inProgress: number;
|
||||||
|
pending: number;
|
||||||
|
efficiency: number;
|
||||||
|
quality: number;
|
||||||
|
}): StatsCardProps[] => [
|
||||||
|
businessMetrics.production.dailyTarget(data.dailyTarget),
|
||||||
|
businessMetrics.production.completed(data.completed),
|
||||||
|
businessMetrics.production.inProgress(data.inProgress),
|
||||||
|
businessMetrics.production.pending(data.pending),
|
||||||
|
businessMetrics.production.efficiency(data.efficiency),
|
||||||
|
businessMetrics.production.quality(data.quality),
|
||||||
|
],
|
||||||
|
|
||||||
|
sales: (data: {
|
||||||
|
revenue: number;
|
||||||
|
revenueGrowth?: number;
|
||||||
|
orders: number;
|
||||||
|
ordersGrowth?: number;
|
||||||
|
customers: number;
|
||||||
|
}): StatsCardProps[] => [
|
||||||
|
businessMetrics.sales.revenue(
|
||||||
|
data.revenue,
|
||||||
|
data.revenueGrowth ? {
|
||||||
|
value: data.revenueGrowth,
|
||||||
|
direction: data.revenueGrowth >= 0 ? 'up' : 'down',
|
||||||
|
label: '%',
|
||||||
|
} : undefined
|
||||||
|
),
|
||||||
|
businessMetrics.sales.orders(
|
||||||
|
data.orders,
|
||||||
|
data.ordersGrowth ? {
|
||||||
|
value: data.ordersGrowth,
|
||||||
|
direction: data.ordersGrowth >= 0 ? 'up' : 'down',
|
||||||
|
label: '%',
|
||||||
|
} : undefined
|
||||||
|
),
|
||||||
|
businessMetrics.sales.customers(data.customers),
|
||||||
|
],
|
||||||
|
|
||||||
|
inventory: (data: {
|
||||||
|
totalItems: number;
|
||||||
|
lowStock: number;
|
||||||
|
outOfStock: number;
|
||||||
|
}): StatsCardProps[] => [
|
||||||
|
businessMetrics.inventory.totalItems(data.totalItems),
|
||||||
|
businessMetrics.inventory.lowStock(data.lowStock),
|
||||||
|
businessMetrics.inventory.outOfStock(data.outOfStock),
|
||||||
|
],
|
||||||
|
};
|
||||||
4
frontend/src/components/ui/Stats/index.ts
Normal file
4
frontend/src/components/ui/Stats/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as StatsCard } from './StatsCard';
|
||||||
|
export { default as StatsGrid } from './StatsGrid';
|
||||||
|
export type { StatsCardProps, StatsCardVariant, StatsCardSize } from './StatsCard';
|
||||||
|
export type { StatsGridProps } from './StatsGrid';
|
||||||
@@ -13,6 +13,7 @@ export { ThemeToggle } from './ThemeToggle';
|
|||||||
export { ProgressBar } from './ProgressBar';
|
export { ProgressBar } from './ProgressBar';
|
||||||
export { StatusIndicator } from './StatusIndicator';
|
export { StatusIndicator } from './StatusIndicator';
|
||||||
export { ListItem } from './ListItem';
|
export { ListItem } from './ListItem';
|
||||||
|
export { StatsCard, StatsGrid } from './Stats';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { ButtonProps } from './Button';
|
export type { ButtonProps } from './Button';
|
||||||
@@ -28,4 +29,5 @@ export type { DatePickerProps } from './DatePicker';
|
|||||||
export type { ThemeToggleProps } from './ThemeToggle';
|
export type { ThemeToggleProps } from './ThemeToggle';
|
||||||
export type { ProgressBarProps } from './ProgressBar';
|
export type { ProgressBarProps } from './ProgressBar';
|
||||||
export type { StatusIndicatorProps } from './StatusIndicator';
|
export type { StatusIndicatorProps } from './StatusIndicator';
|
||||||
export type { ListItemProps } from './ListItem';
|
export type { ListItemProps } from './ListItem';
|
||||||
|
export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps } from './Stats';
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Download, AlertTriangle } from 'lucide-react';
|
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||||
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { InventoryTable, InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||||
|
|
||||||
const InventoryPage: React.FC = () => {
|
const InventoryPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState(null);
|
const [selectedItem, setSelectedItem] = useState<typeof mockInventoryItems[0] | null>(null);
|
||||||
const [filterCategory, setFilterCategory] = useState('all');
|
|
||||||
const [filterStatus, setFilterStatus] = useState('all');
|
|
||||||
|
|
||||||
const mockInventoryItems = [
|
const mockInventoryItems = [
|
||||||
{
|
{
|
||||||
@@ -54,157 +53,355 @@ const InventoryPage: React.FC = () => {
|
|||||||
expirationDate: '2024-02-10',
|
expirationDate: '2024-02-10',
|
||||||
status: 'normal',
|
status: 'normal',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Azúcar Blanco',
|
||||||
|
category: 'Azúcares',
|
||||||
|
currentStock: 0,
|
||||||
|
minStock: 15,
|
||||||
|
maxStock: 50,
|
||||||
|
unit: 'kg',
|
||||||
|
cost: 0.95,
|
||||||
|
supplier: 'Distribuidora Central',
|
||||||
|
lastRestocked: '2024-01-10',
|
||||||
|
expirationDate: '2024-12-31',
|
||||||
|
status: 'out',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'Leche Entera',
|
||||||
|
category: 'Lácteos',
|
||||||
|
currentStock: 3,
|
||||||
|
minStock: 10,
|
||||||
|
maxStock: 40,
|
||||||
|
unit: 'L',
|
||||||
|
cost: 1.45,
|
||||||
|
supplier: 'Lácteos Frescos',
|
||||||
|
lastRestocked: '2024-01-22',
|
||||||
|
expirationDate: '2024-01-28',
|
||||||
|
status: 'expired',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const lowStockItems = mockInventoryItems.filter(item => item.status === 'low');
|
const getStockStatusBadge = (item: typeof mockInventoryItems[0]) => {
|
||||||
|
const { currentStock, minStock, status } = item;
|
||||||
const stats = {
|
|
||||||
totalItems: mockInventoryItems.length,
|
if (status === 'expired') {
|
||||||
lowStockItems: lowStockItems.length,
|
return (
|
||||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
<Badge
|
||||||
needsReorder: lowStockItems.length,
|
variant="error"
|
||||||
|
icon={<AlertTriangle size={12} />}
|
||||||
|
text="Caducado"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStock === 0) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="error"
|
||||||
|
icon={<AlertTriangle size={12} />}
|
||||||
|
text="Sin Stock"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStock <= minStock) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="warning"
|
||||||
|
icon={<AlertTriangle size={12} />}
|
||||||
|
text="Stock Bajo"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="success"
|
||||||
|
icon={<CheckCircle size={12} />}
|
||||||
|
text="Normal"
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCategoryBadge = (category: string) => {
|
||||||
|
const categoryConfig = {
|
||||||
|
'Harinas': { color: 'default' },
|
||||||
|
'Levaduras': { color: 'info' },
|
||||||
|
'Lácteos': { color: 'secondary' },
|
||||||
|
'Grasas': { color: 'warning' },
|
||||||
|
'Azúcares': { color: 'primary' },
|
||||||
|
'Especias': { color: 'success' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = categoryConfig[category as keyof typeof categoryConfig] || { color: 'default' };
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config.color as any}
|
||||||
|
text={category}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredItems = mockInventoryItems.filter(item => {
|
||||||
|
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
item.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
item.supplier.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
return matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowStockItems = mockInventoryItems.filter(item =>
|
||||||
|
item.currentStock <= item.minStock || item.status === 'low' || item.status === 'out' || item.status === 'expired'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockInventoryStats = {
|
||||||
|
totalItems: mockInventoryItems.length,
|
||||||
|
lowStockItems: lowStockItems.length,
|
||||||
|
outOfStock: mockInventoryItems.filter(item => item.currentStock === 0).length,
|
||||||
|
expiringSoon: mockInventoryItems.filter(item => item.status === 'expired').length,
|
||||||
|
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
||||||
|
categories: [...new Set(mockInventoryItems.map(item => item.category))].length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inventoryStats = [
|
||||||
|
{
|
||||||
|
title: 'Total Artículos',
|
||||||
|
value: mockInventoryStats.totalItems,
|
||||||
|
variant: 'default' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Stock Bajo',
|
||||||
|
value: mockInventoryStats.lowStockItems,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sin Stock',
|
||||||
|
value: mockInventoryStats.outOfStock,
|
||||||
|
variant: 'error' as const,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Por Caducar',
|
||||||
|
value: mockInventoryStats.expiringSoon,
|
||||||
|
variant: 'error' as const,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Valor Total',
|
||||||
|
value: formatters.currency(mockInventoryStats.totalValue),
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Categorías',
|
||||||
|
value: mockInventoryStats.categories,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Gestión de Inventario"
|
title="Gestión de Inventario"
|
||||||
description="Controla el stock de ingredientes y materias primas"
|
description="Controla el stock de ingredientes y materias primas"
|
||||||
action={
|
actions={[
|
||||||
<Button onClick={() => setShowForm(true)}>
|
{
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
id: "export",
|
||||||
Nuevo Artículo
|
label: "Exportar",
|
||||||
</Button>
|
variant: "outline" as const,
|
||||||
}
|
icon: Download,
|
||||||
|
onClick: () => console.log('Export inventory')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
label: "Nuevo Artículo",
|
||||||
|
variant: "primary" as const,
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => setShowForm(true)
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<StatsGrid
|
||||||
<Card className="p-6">
|
stats={inventoryStats}
|
||||||
<div className="flex items-center justify-between">
|
columns={6}
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Artículos</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{stats.totalItems}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-6 w-6 text-[var(--color-info)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4-8-4m16 0v10l-8 4-8-4V7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Stock Bajo</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-error)]">{stats.lowStockItems}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<AlertTriangle className="h-6 w-6 text-[var(--color-error)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Valor Total</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-success)]">€{stats.totalValue.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-6 w-6 text-[var(--color-success)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Necesita Reorden</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{stats.needsReorder}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Low Stock Alert */}
|
{/* Low Stock Alert */}
|
||||||
{lowStockItems.length > 0 && (
|
{lowStockItems.length > 0 && (
|
||||||
<LowStockAlert items={lowStockItems} />
|
<LowStockAlert items={lowStockItems} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Simplified Controls */}
|
||||||
<Card className="p-6">
|
<Card className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<Input
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
placeholder="Buscar artículos por nombre, categoría o proveedor..."
|
||||||
<Input
|
value={searchTerm}
|
||||||
placeholder="Buscar artículos..."
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
value={searchTerm}
|
className="w-full"
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
/>
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={filterCategory}
|
|
||||||
onChange={(e) => setFilterCategory(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
>
|
|
||||||
<option value="all">Todas las categorías</option>
|
|
||||||
<option value="Harinas">Harinas</option>
|
|
||||||
<option value="Levaduras">Levaduras</option>
|
|
||||||
<option value="Lácteos">Lácteos</option>
|
|
||||||
<option value="Grasas">Grasas</option>
|
|
||||||
<option value="Azúcares">Azúcares</option>
|
|
||||||
<option value="Especias">Especias</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los estados</option>
|
|
||||||
<option value="normal">Stock normal</option>
|
|
||||||
<option value="low">Stock bajo</option>
|
|
||||||
<option value="out">Sin stock</option>
|
|
||||||
<option value="expired">Caducado</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button variant="outline">
|
|
||||||
<Filter className="w-4 h-4 mr-2" />
|
|
||||||
Más filtros
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Inventory Table */}
|
{/* Inventory Items Grid */}
|
||||||
<Card>
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<InventoryTable
|
{filteredItems.map((item) => (
|
||||||
data={mockInventoryItems}
|
<Card key={item.id} className="p-4">
|
||||||
onEdit={(item) => {
|
<div className="space-y-4">
|
||||||
setSelectedItem(item);
|
{/* Header */}
|
||||||
setShowForm(true);
|
<div className="flex items-start justify-between">
|
||||||
}}
|
<div className="flex items-start gap-3">
|
||||||
/>
|
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
||||||
</Card>
|
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{item.supplier}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStockStatusBadge(item)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category and Stock */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{getCategoryBadge(item.category)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||||
|
{item.currentStock} {item.unit}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Mín: {item.minStock} | Máx: {item.maxStock}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value and Dates */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-[var(--text-secondary)]">Costo unitario:</div>
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">
|
||||||
|
{formatters.currency(item.cost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[var(--text-secondary)]">Valor total:</div>
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">
|
||||||
|
{formatters.currency(item.currentStock * item.cost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Último restock:</span>
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{new Date(item.lastRestocked).toLocaleDateString('es-ES')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Caducidad:</span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
new Date(item.expirationDate) < new Date()
|
||||||
|
? 'text-[var(--color-error)]'
|
||||||
|
: new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
? 'text-[var(--color-warning)]'
|
||||||
|
: 'text-[var(--text-primary)]'
|
||||||
|
}`}>
|
||||||
|
{new Date(item.expirationDate).toLocaleDateString('es-ES')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock Level Progress */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">Nivel de stock</span>
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{Math.round((item.currentStock / item.maxStock) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
item.currentStock <= item.minStock
|
||||||
|
? 'bg-[var(--color-error)]'
|
||||||
|
: item.currentStock <= item.minStock * 1.5
|
||||||
|
? 'bg-[var(--color-warning)]'
|
||||||
|
: 'bg-[var(--color-success)]'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min((item.currentStock / item.maxStock) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredItems.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron artículos
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nuevo Artículo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inventory Form Modal */}
|
{/* Inventory Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Download, Calendar, Clock, User, Package } from 'lucide-react';
|
import { Plus, Download, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, Table } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||||
import type { TableColumn } from '../../../../components/ui';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { OrdersTable, OrderForm } from '../../../../components/domain/sales';
|
import { OrderForm } from '../../../../components/domain/sales';
|
||||||
|
|
||||||
const OrdersPage: React.FC = () => {
|
const OrdersPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [activeTab] = useState('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
const [selectedOrder, setSelectedOrder] = useState<typeof mockOrders[0] | null>(null);
|
||||||
|
|
||||||
const mockOrders = [
|
const mockOrders = [
|
||||||
{
|
{
|
||||||
@@ -82,148 +82,24 @@ const OrdersPage: React.FC = () => {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'yellow', text: 'Pendiente' },
|
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
||||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
in_progress: { color: 'info', text: 'En Proceso', icon: Timer },
|
||||||
ready: { color: 'green', text: 'Listo' },
|
ready: { color: 'success', text: 'Listo', icon: CheckCircle },
|
||||||
completed: { color: 'green', text: 'Completado' },
|
completed: { color: 'success', text: 'Completado', icon: CheckCircle },
|
||||||
cancelled: { color: 'red', text: 'Cancelado' },
|
cancelled: { color: 'error', text: 'Cancelado', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
const Icon = config?.icon;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config?.color as any}
|
||||||
|
icon={Icon && <Icon size={12} />}
|
||||||
|
text={config?.text || status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityBadge = (priority: string) => {
|
|
||||||
const priorityConfig = {
|
|
||||||
low: { color: 'gray', text: 'Baja' },
|
|
||||||
normal: { color: 'blue', text: 'Normal' },
|
|
||||||
high: { color: 'orange', text: 'Alta' },
|
|
||||||
urgent: { color: 'red', text: 'Urgente' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
|
||||||
return <Badge variant={config?.color as any}>{config?.text || priority}</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentStatusBadge = (status: string) => {
|
|
||||||
const statusConfig = {
|
|
||||||
pending: { color: 'yellow', text: 'Pendiente' },
|
|
||||||
paid: { color: 'green', text: 'Pagado' },
|
|
||||||
failed: { color: 'red', text: 'Fallido' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
|
||||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: TableColumn[] = [
|
|
||||||
{
|
|
||||||
key: 'id',
|
|
||||||
title: 'Pedido',
|
|
||||||
dataIndex: 'id',
|
|
||||||
render: (value, record: any) => (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{value}</div>
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">{record.deliveryMethod === 'delivery' ? 'Entrega' : 'Recogida'}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'customer',
|
|
||||||
title: 'Cliente',
|
|
||||||
render: (_, record: any) => (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<User className="h-4 w-4 text-[var(--text-tertiary)] mr-2" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{record.customerName}</div>
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">{record.customerEmail}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: 'Estado',
|
|
||||||
dataIndex: 'status',
|
|
||||||
render: (value) => getStatusBadge(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'priority',
|
|
||||||
title: 'Prioridad',
|
|
||||||
dataIndex: 'priority',
|
|
||||||
render: (value) => getPriorityBadge(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'orderDate',
|
|
||||||
title: 'Fecha Pedido',
|
|
||||||
dataIndex: 'orderDate',
|
|
||||||
render: (value) => (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-[var(--text-primary)]">{new Date(value).toLocaleDateString('es-ES')}</div>
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
{new Date(value).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deliveryDate',
|
|
||||||
title: 'Entrega',
|
|
||||||
dataIndex: 'deliveryDate',
|
|
||||||
render: (value) => (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-[var(--text-primary)]">{new Date(value).toLocaleDateString('es-ES')}</div>
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
{new Date(value).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'total',
|
|
||||||
title: 'Total',
|
|
||||||
dataIndex: 'total',
|
|
||||||
render: (value, record: any) => (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">€{value.toFixed(2)}</div>
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">{record.items.length} artículos</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'payment',
|
|
||||||
title: 'Pago',
|
|
||||||
render: (_, record: any) => (
|
|
||||||
<div>
|
|
||||||
{getPaymentStatusBadge(record.paymentStatus)}
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)] capitalize">{record.paymentMethod}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
title: 'Acciones',
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (_, record: any) => (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrder(record);
|
|
||||||
setShowForm(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ver
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const filteredOrders = mockOrders.filter(order => {
|
const filteredOrders = mockOrders.filter(order => {
|
||||||
const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@@ -234,181 +110,211 @@ const OrdersPage: React.FC = () => {
|
|||||||
return matchesSearch && matchesTab;
|
return matchesSearch && matchesTab;
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats = {
|
const mockOrderStats = {
|
||||||
total: mockOrders.length,
|
total: mockOrders.length,
|
||||||
pending: mockOrders.filter(o => o.status === 'pending').length,
|
pending: mockOrders.filter(o => o.status === 'pending').length,
|
||||||
inProgress: mockOrders.filter(o => o.status === 'in_progress').length,
|
inProgress: mockOrders.filter(o => o.status === 'in_progress').length,
|
||||||
completed: mockOrders.filter(o => o.status === 'completed').length,
|
completed: mockOrders.filter(o => o.status === 'completed').length,
|
||||||
|
cancelled: mockOrders.filter(o => o.status === 'cancelled').length,
|
||||||
totalRevenue: mockOrders.reduce((sum, order) => sum + order.total, 0),
|
totalRevenue: mockOrders.reduce((sum, order) => sum + order.total, 0),
|
||||||
|
averageOrder: mockOrders.reduce((sum, order) => sum + order.total, 0) / mockOrders.length,
|
||||||
|
todayOrders: mockOrders.filter(o =>
|
||||||
|
new Date(o.orderDate).toDateString() === new Date().toDateString()
|
||||||
|
).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [
|
const stats = [
|
||||||
{ id: 'all', label: 'Todos', count: stats.total },
|
{
|
||||||
{ id: 'pending', label: 'Pendientes', count: stats.pending },
|
title: 'Total Pedidos',
|
||||||
{ id: 'in_progress', label: 'En Proceso', count: stats.inProgress },
|
value: mockOrderStats.total,
|
||||||
{ id: 'ready', label: 'Listos', count: 0 },
|
variant: 'default' as const,
|
||||||
{ id: 'completed', label: 'Completados', count: stats.completed },
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pendientes',
|
||||||
|
value: mockOrderStats.pending,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'En Proceso',
|
||||||
|
value: mockOrderStats.inProgress,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Timer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Completados',
|
||||||
|
value: mockOrderStats.completed,
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ingresos Total',
|
||||||
|
value: formatters.currency(mockOrderStats.totalRevenue),
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Promedio',
|
||||||
|
value: formatters.currency(mockOrderStats.averageOrder),
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Gestión de Pedidos"
|
title="Gestión de Pedidos"
|
||||||
description="Administra y controla todos los pedidos de tu panadería"
|
description="Administra y supervisa todos los pedidos de la panadería"
|
||||||
action={
|
actions={[
|
||||||
|
{
|
||||||
|
id: "export",
|
||||||
|
label: "Exportar",
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: Download,
|
||||||
|
onClick: () => console.log('Export orders')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
label: "Nuevo Pedido",
|
||||||
|
variant: "primary" as const,
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => setShowForm(true)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<StatsGrid
|
||||||
|
stats={stats}
|
||||||
|
columns={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Simplified Controls */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar pedidos por cliente, ID o email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Orders Grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<Card key={order.id} className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
||||||
|
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-[var(--color-primary)]">
|
||||||
|
{order.id}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{order.customerName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(order.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Info */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||||
|
{formatters.currency(order.total)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{order.items?.length} artículos
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-[var(--text-primary)]">
|
||||||
|
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Entrega: {new Date(order.deliveryDate).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredOrders.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron pedidos
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Intenta ajustar la búsqueda o crear un nuevo pedido
|
||||||
|
</p>
|
||||||
<Button onClick={() => setShowForm(true)}>
|
<Button onClick={() => setShowForm(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Nuevo Pedido
|
Nuevo Pedido
|
||||||
</Button>
|
</Button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Pedidos</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{stats.total}</p>
|
|
||||||
</div>
|
|
||||||
<Package className="h-8 w-8 text-[var(--color-info)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Pendientes</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.pending}</p>
|
|
||||||
</div>
|
|
||||||
<Clock className="h-8 w-8 text-[var(--color-primary)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">En Proceso</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.inProgress}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-5 w-5 text-[var(--color-info)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completados</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.completed}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-5 w-5 text-[var(--color-success)]" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ingresos</p>
|
|
||||||
<p className="text-2xl font-bold text-purple-600">€{stats.totalRevenue.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
|
||||||
<div className="border-b border-[var(--border-primary)]">
|
|
||||||
<nav className="-mb-px flex space-x-8">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-orange-500 text-[var(--color-primary)]'
|
|
||||||
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{tab.count > 0 && (
|
|
||||||
<span className="ml-2 bg-[var(--bg-tertiary)] text-[var(--text-primary)] py-0.5 px-2.5 rounded-full text-xs">
|
|
||||||
{tab.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search and Filters */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar pedidos por cliente, ID o email..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline">
|
|
||||||
<Filter className="w-4 h-4 mr-2" />
|
|
||||||
Filtros
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
|
||||||
Fecha
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
{/* Orders Table */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={filteredOrders}
|
|
||||||
rowKey="id"
|
|
||||||
hover={true}
|
|
||||||
variant="default"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Order Form Modal */}
|
{/* Order Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<OrderForm
|
<OrderForm
|
||||||
order={selectedOrder}
|
orderId={selectedOrder?.id}
|
||||||
onClose={() => {
|
onOrderCancel={() => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setSelectedOrder(null);
|
setSelectedOrder(null);
|
||||||
}}
|
}}
|
||||||
onSave={(order) => {
|
onOrderSave={async (order: any) => {
|
||||||
// Handle save logic
|
// Handle save logic
|
||||||
console.log('Saving order:', order);
|
console.log('Saving order:', order);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setSelectedOrder(null);
|
setSelectedOrder(null);
|
||||||
|
return true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Download, ShoppingCart, Truck, DollarSign, Calendar } from 'lucide-react';
|
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Edit } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, Table } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||||
import type { TableColumn } from '../../../../components/ui';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
|
||||||
const ProcurementPage: React.FC = () => {
|
const ProcurementPage: React.FC = () => {
|
||||||
@@ -103,152 +103,126 @@ const ProcurementPage: React.FC = () => {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'yellow', text: 'Pendiente' },
|
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
||||||
approved: { color: 'blue', text: 'Aprobado' },
|
approved: { color: 'info', text: 'Aprobado', icon: CheckCircle },
|
||||||
in_transit: { color: 'purple', text: 'En Tránsito' },
|
in_transit: { color: 'secondary', text: 'En Tránsito', icon: Truck },
|
||||||
delivered: { color: 'green', text: 'Entregado' },
|
delivered: { color: 'success', text: 'Entregado', icon: CheckCircle },
|
||||||
cancelled: { color: 'red', text: 'Cancelado' },
|
cancelled: { color: 'error', text: 'Cancelado', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
const Icon = config?.icon;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config?.color as any}
|
||||||
|
icon={Icon && <Icon size={12} />}
|
||||||
|
text={config?.text || status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentStatusBadge = (status: string) => {
|
const getPaymentStatusBadge = (status: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'yellow', text: 'Pendiente' },
|
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
||||||
paid: { color: 'green', text: 'Pagado' },
|
paid: { color: 'success', text: 'Pagado', icon: CheckCircle },
|
||||||
overdue: { color: 'red', text: 'Vencido' },
|
overdue: { color: 'error', text: 'Vencido', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
const Icon = config?.icon;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config?.color as any}
|
||||||
|
icon={Icon && <Icon size={12} />}
|
||||||
|
text={config?.text || status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: TableColumn[] = [
|
const filteredOrders = mockPurchaseOrders.filter(order => {
|
||||||
{
|
const matchesSearch = order.supplier.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
key: 'id',
|
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
title: 'Orden',
|
order.notes.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
dataIndex: 'id',
|
|
||||||
render: (value, record: any) => (
|
return matchesSearch;
|
||||||
<div>
|
});
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{value}</div>
|
|
||||||
{record.notes && (
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">{record.notes}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'supplier',
|
|
||||||
title: 'Proveedor',
|
|
||||||
dataIndex: 'supplier',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
title: 'Estado',
|
|
||||||
dataIndex: 'status',
|
|
||||||
render: (value) => getStatusBadge(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'orderDate',
|
|
||||||
title: 'Fecha Pedido',
|
|
||||||
dataIndex: 'orderDate',
|
|
||||||
render: (value) => new Date(value).toLocaleDateString('es-ES'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deliveryDate',
|
|
||||||
title: 'Fecha Entrega',
|
|
||||||
dataIndex: 'deliveryDate',
|
|
||||||
render: (value) => new Date(value).toLocaleDateString('es-ES'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'totalAmount',
|
|
||||||
title: 'Monto Total',
|
|
||||||
dataIndex: 'totalAmount',
|
|
||||||
render: (value) => `€${value.toLocaleString()}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'paymentStatus',
|
|
||||||
title: 'Pago',
|
|
||||||
dataIndex: 'paymentStatus',
|
|
||||||
render: (value) => getPaymentStatusBadge(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
title: 'Acciones',
|
|
||||||
align: 'right' as const,
|
|
||||||
render: () => (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" size="sm">Ver</Button>
|
|
||||||
<Button variant="outline" size="sm">Editar</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const stats = {
|
const mockPurchaseStats = {
|
||||||
totalOrders: mockPurchaseOrders.length,
|
totalOrders: mockPurchaseOrders.length,
|
||||||
pendingOrders: mockPurchaseOrders.filter(o => o.status === 'pending').length,
|
pendingOrders: mockPurchaseOrders.filter(o => o.status === 'pending').length,
|
||||||
|
inTransit: mockPurchaseOrders.filter(o => o.status === 'in_transit').length,
|
||||||
|
delivered: mockPurchaseOrders.filter(o => o.status === 'delivered').length,
|
||||||
totalSpent: mockPurchaseOrders.reduce((sum, order) => sum + order.totalAmount, 0),
|
totalSpent: mockPurchaseOrders.reduce((sum, order) => sum + order.totalAmount, 0),
|
||||||
activeSuppliers: mockSuppliers.filter(s => s.status === 'active').length,
|
activeSuppliers: mockSuppliers.filter(s => s.status === 'active').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const purchaseOrderStats = [
|
||||||
|
{
|
||||||
|
title: 'Total Órdenes',
|
||||||
|
value: mockPurchaseStats.totalOrders,
|
||||||
|
variant: 'default' as const,
|
||||||
|
icon: ShoppingCart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pendientes',
|
||||||
|
value: mockPurchaseStats.pendingOrders,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'En Tránsito',
|
||||||
|
value: mockPurchaseStats.inTransit,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Truck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Entregadas',
|
||||||
|
value: mockPurchaseStats.delivered,
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gasto Total',
|
||||||
|
value: formatters.currency(mockPurchaseStats.totalSpent),
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Proveedores',
|
||||||
|
value: mockPurchaseStats.activeSuppliers,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Gestión de Compras"
|
title="Gestión de Compras"
|
||||||
description="Administra órdenes de compra, proveedores y seguimiento de entregas"
|
description="Administra órdenes de compra, proveedores y seguimiento de entregas"
|
||||||
action={
|
actions={[
|
||||||
<Button>
|
{
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
id: "export",
|
||||||
Nueva Orden de Compra
|
label: "Exportar",
|
||||||
</Button>
|
variant: "outline" as const,
|
||||||
}
|
icon: Download,
|
||||||
|
onClick: () => console.log('Export purchase orders')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
label: "Nueva Orden de Compra",
|
||||||
|
variant: "primary" as const,
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => console.log('New purchase order')
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<StatsGrid
|
||||||
<Card className="p-6">
|
stats={purchaseOrderStats}
|
||||||
<div className="flex items-center justify-between">
|
columns={6}
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Órdenes Totales</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{stats.totalOrders}</p>
|
|
||||||
</div>
|
|
||||||
<ShoppingCart className="h-12 w-12 text-[var(--color-info)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Órdenes Pendientes</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{stats.pendingOrders}</p>
|
|
||||||
</div>
|
|
||||||
<Calendar className="h-12 w-12 text-[var(--color-primary)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Gasto Total</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-success)]">€{stats.totalSpent.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<DollarSign className="h-12 w-12 text-[var(--color-success)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Proveedores Activos</p>
|
|
||||||
<p className="text-3xl font-bold text-purple-600">{stats.activeSuppliers}</p>
|
|
||||||
</div>
|
|
||||||
<Truck className="h-12 w-12 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<div className="border-b border-[var(--border-primary)]">
|
<div className="border-b border-[var(--border-primary)]">
|
||||||
@@ -286,48 +260,128 @@ const ProcurementPage: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{activeTab === 'orders' && (
|
||||||
<Card className="p-6">
|
<Card className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<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
|
<Input
|
||||||
placeholder={`Buscar ${activeTab === 'orders' ? 'órdenes' : 'proveedores'}...`}
|
placeholder="Buscar órdenes por proveedor, ID o notas..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline">
|
|
||||||
<Filter className="w-4 h-4 mr-2" />
|
|
||||||
Filtros
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Exportar
|
Exportar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === 'orders' && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={mockPurchaseOrders}
|
|
||||||
rowKey="id"
|
|
||||||
hover={true}
|
|
||||||
variant="default"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Purchase Orders Grid */}
|
||||||
|
{activeTab === 'orders' && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<Card key={order.id} className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
||||||
|
<ShoppingCart className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-[var(--color-primary)]">
|
||||||
|
{order.id}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{order.supplier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(order.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Info */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||||
|
{formatters.currency(order.totalAmount)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{order.items?.length} artículos
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-[var(--text-primary)]">
|
||||||
|
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Entrega prevista
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Status */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Estado del pago:
|
||||||
|
</div>
|
||||||
|
{getPaymentStatusBadge(order.paymentStatus)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{order.notes && (
|
||||||
|
<div className="bg-[var(--bg-secondary)] p-2 rounded text-xs text-[var(--text-secondary)] italic">
|
||||||
|
"{order.notes}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => console.log('View order', order.id)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => console.log('Edit order', order.id)}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State for Purchase Orders */}
|
||||||
|
{activeTab === 'orders' && filteredOrders.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ShoppingCart className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron órdenes de compra
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Intenta ajustar la búsqueda o crear una nueva orden de compra
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => console.log('New purchase order')}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nueva Orden de Compra
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'suppliers' && (
|
{activeTab === 'suppliers' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{mockSuppliers.map((supplier) => (
|
{mockSuppliers.map((supplier) => (
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Calendar, Clock, Users, AlertCircle, Search, Download, Filter } from 'lucide-react';
|
import { Plus, Download, Clock, Users, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Calendar, Zap } from 'lucide-react';
|
||||||
import { Button, Card, Badge } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||||
import { DataTable } from '../../../../components/shared';
|
import { pagePresets } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import type { DataTableColumn, DataTableFilter, DataTablePagination, DataTableSelection } from '../../../../components/shared';
|
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
||||||
|
|
||||||
const ProductionPage: React.FC = () => {
|
const ProductionPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState('schedule');
|
const [activeTab, setActiveTab] = useState('schedule');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filters, setFilters] = useState<DataTableFilter[]>([]);
|
const [selectedOrder, setSelectedOrder] = useState<typeof mockProductionOrders[0] | null>(null);
|
||||||
const [selectedBatches, setSelectedBatches] = useState<any[]>([]);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [pagination, setPagination] = useState<DataTablePagination>({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
total: 8 // Updated to match the number of mock orders
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const mockProductionStats = {
|
const mockProductionStats = {
|
||||||
dailyTarget: 150,
|
dailyTarget: 150,
|
||||||
@@ -27,41 +20,6 @@ const ProductionPage: React.FC = () => {
|
|||||||
quality: 94,
|
quality: 94,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler functions for table actions
|
|
||||||
const handleViewBatch = (batch: any) => {
|
|
||||||
console.log('Ver lote:', batch);
|
|
||||||
// Implement view logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditBatch = (batch: any) => {
|
|
||||||
console.log('Editar lote:', batch);
|
|
||||||
// Implement edit logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (query: string) => {
|
|
||||||
setSearchQuery(query);
|
|
||||||
// Implement search logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFiltersChange = (newFilters: DataTableFilter[]) => {
|
|
||||||
setFilters(newFilters);
|
|
||||||
// Implement filtering logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: number, pageSize: number) => {
|
|
||||||
setPagination(prev => ({ ...prev, page, pageSize }));
|
|
||||||
// Implement pagination logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatchSelection = (selectedRows: any[]) => {
|
|
||||||
setSelectedBatches(selectedRows);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = (format: 'csv' | 'xlsx') => {
|
|
||||||
console.log(`Exportando en formato ${format}`);
|
|
||||||
// Implement export logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProductionOrders = [
|
const mockProductionOrders = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
@@ -155,248 +113,78 @@ const ProductionPage: React.FC = () => {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending: { color: 'yellow', text: 'Pendiente' },
|
pending: { color: 'warning', text: 'Pendiente', icon: Clock },
|
||||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
in_progress: { color: 'info', text: 'En Proceso', icon: Timer },
|
||||||
completed: { color: 'green', text: 'Completado' },
|
completed: { color: 'success', text: 'Completado', icon: CheckCircle },
|
||||||
cancelled: { color: 'red', text: 'Cancelado' },
|
cancelled: { color: 'error', text: 'Cancelado', icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status as keyof typeof statusConfig];
|
const config = statusConfig[status as keyof typeof statusConfig];
|
||||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
const Icon = config?.icon;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config?.color as any}
|
||||||
|
icon={Icon && <Icon size={12} />}
|
||||||
|
text={config?.text || status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityBadge = (priority: string) => {
|
const getPriorityBadge = (priority: string) => {
|
||||||
const priorityConfig = {
|
const priorityConfig = {
|
||||||
low: { color: 'gray', text: 'Baja' },
|
low: { color: 'outline', text: 'Baja' },
|
||||||
medium: { color: 'yellow', text: 'Media' },
|
medium: { color: 'secondary', text: 'Media' },
|
||||||
high: { color: 'orange', text: 'Alta' },
|
high: { color: 'warning', text: 'Alta' },
|
||||||
urgent: { color: 'red', text: 'Urgente' },
|
urgent: { color: 'error', text: 'Urgente', icon: Zap },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
const Icon = config?.icon;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config?.color as any}
|
||||||
|
icon={Icon && <Icon size={12} />}
|
||||||
|
text={config?.text || priority}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const filteredOrders = mockProductionOrders.filter(order => {
|
||||||
{
|
const matchesSearch = order.recipeName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
id: 'recipeName',
|
order.assignedTo.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
key: 'recipeName',
|
order.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
header: 'Receta',
|
|
||||||
sortable: true,
|
return matchesSearch;
|
||||||
filterable: true,
|
});
|
||||||
type: 'text',
|
|
||||||
width: 200,
|
|
||||||
cell: (value) => (
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{value}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'quantity',
|
|
||||||
key: 'quantity',
|
|
||||||
header: 'Cantidad',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
type: 'number',
|
|
||||||
width: 120,
|
|
||||||
align: 'center',
|
|
||||||
cell: (value) => `${value} unidades`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status',
|
|
||||||
key: 'status',
|
|
||||||
header: 'Estado',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
type: 'select',
|
|
||||||
width: 130,
|
|
||||||
align: 'center',
|
|
||||||
selectOptions: [
|
|
||||||
{ value: 'pending', label: 'Pendiente' },
|
|
||||||
{ value: 'in_progress', label: 'En Proceso' },
|
|
||||||
{ value: 'completed', label: 'Completado' },
|
|
||||||
{ value: 'cancelled', label: 'Cancelado' }
|
|
||||||
],
|
|
||||||
cell: (value) => getStatusBadge(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'priority',
|
|
||||||
key: 'priority',
|
|
||||||
header: 'Prioridad',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
type: 'select',
|
|
||||||
width: 120,
|
|
||||||
align: 'center',
|
|
||||||
selectOptions: [
|
|
||||||
{ value: 'low', label: 'Baja' },
|
|
||||||
{ value: 'medium', label: 'Media' },
|
|
||||||
{ value: 'high', label: 'Alta' },
|
|
||||||
{ value: 'urgent', label: 'Urgente' }
|
|
||||||
],
|
|
||||||
cell: (value) => getPriorityBadge(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'assignedTo',
|
|
||||||
key: 'assignedTo',
|
|
||||||
header: 'Asignado a',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
type: 'text',
|
|
||||||
width: 180,
|
|
||||||
hideOnMobile: true,
|
|
||||||
cell: (value) => (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Users className="h-4 w-4 text-[var(--text-tertiary)] mr-2" />
|
|
||||||
<span className="text-sm text-[var(--text-primary)]">{value}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'progress',
|
|
||||||
key: 'progress',
|
|
||||||
header: 'Progreso',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
type: 'number',
|
|
||||||
width: 150,
|
|
||||||
align: 'center',
|
|
||||||
hideOnMobile: true,
|
|
||||||
cell: (value) => (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2 mr-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full"
|
|
||||||
style={{ width: `${value}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--text-primary)]">{value}%</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'estimatedCompletion',
|
|
||||||
key: 'estimatedCompletion',
|
|
||||||
header: 'Tiempo Estimado',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
type: 'date',
|
|
||||||
width: 140,
|
|
||||||
align: 'center',
|
|
||||||
hideOnMobile: true,
|
|
||||||
cell: (value) => new Date(value).toLocaleTimeString('es-ES', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
key: 'actions',
|
|
||||||
header: 'Acciones',
|
|
||||||
sortable: false,
|
|
||||||
filterable: false,
|
|
||||||
width: 150,
|
|
||||||
align: 'right',
|
|
||||||
sticky: 'right',
|
|
||||||
cell: (value, row) => (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => handleViewBatch(row)}>
|
|
||||||
Ver
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => handleEditBatch(row)}>
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Gestión de Producción"
|
title="Gestión de Producción"
|
||||||
description="Planifica y controla la producción diaria de tu panadería"
|
description="Planifica y controla la producción diaria de tu panadería"
|
||||||
action={
|
actions={[
|
||||||
<Button>
|
{
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
id: "export",
|
||||||
Nueva Orden de Producción
|
label: "Exportar",
|
||||||
</Button>
|
variant: "outline" as const,
|
||||||
}
|
icon: Download,
|
||||||
|
onClick: () => console.log('Export production orders')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
label: "Nueva Orden de Producción",
|
||||||
|
variant: "primary" as const,
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => setShowForm(true)
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Production Stats */}
|
{/* Production Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
<StatsGrid
|
||||||
<Card className="p-4">
|
stats={pagePresets.production(mockProductionStats)}
|
||||||
<div className="flex items-center justify-between">
|
columns={6}
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Meta Diaria</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{mockProductionStats.dailyTarget}</p>
|
|
||||||
</div>
|
|
||||||
<Calendar className="h-8 w-8 text-[var(--color-info)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Completado</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-success)]">{mockProductionStats.completed}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-5 w-5 text-[var(--color-success)]" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">En Proceso</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{mockProductionStats.inProgress}</p>
|
|
||||||
</div>
|
|
||||||
<Clock className="h-8 w-8 text-[var(--color-info)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Pendiente</p>
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{mockProductionStats.pending}</p>
|
|
||||||
</div>
|
|
||||||
<AlertCircle className="h-8 w-8 text-[var(--color-primary)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Eficiencia</p>
|
|
||||||
<p className="text-2xl font-bold text-purple-600">{mockProductionStats.efficiency}%</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Calidad</p>
|
|
||||||
<p className="text-2xl font-bold text-indigo-600">{mockProductionStats.quality}%</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<div className="border-b border-[var(--border-primary)]">
|
<div className="border-b border-[var(--border-primary)]">
|
||||||
@@ -434,56 +222,162 @@ const ProductionPage: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Production Orders Tab */}
|
||||||
{activeTab === 'schedule' && (
|
{activeTab === 'schedule' && (
|
||||||
<Card>
|
<>
|
||||||
<div className="p-6">
|
{/* Simplified Controls */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<Card className="p-4">
|
||||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">Órdenes de Producción</h3>
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex space-x-2">
|
<div className="flex-1">
|
||||||
{selectedBatches.length > 0 && (
|
<Input
|
||||||
<Button variant="outline" size="sm">
|
placeholder="Buscar órdenes por receta, asignado o ID..."
|
||||||
<Download className="w-4 h-4 mr-2" />
|
value={searchQuery}
|
||||||
Acciones en lote ({selectedBatches.length})
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
</Button>
|
className="w-full"
|
||||||
)}
|
/>
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Filter className="w-4 h-4 mr-2" />
|
|
||||||
Vista Calendario
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
<DataTable
|
|
||||||
data={mockProductionOrders}
|
{/* Production Orders Grid */}
|
||||||
columns={columns}
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
isLoading={isLoading}
|
{filteredOrders.map((order) => (
|
||||||
searchQuery={searchQuery}
|
<Card key={order.id} className="p-4">
|
||||||
onSearchChange={handleSearchChange}
|
<div className="space-y-4">
|
||||||
searchPlaceholder="Buscar por receta, asignado, estado..."
|
{/* Header */}
|
||||||
filters={filters}
|
<div className="flex items-start justify-between">
|
||||||
onFiltersChange={handleFiltersChange}
|
<div className="flex items-start gap-3">
|
||||||
pagination={pagination}
|
<div className="flex-shrink-0 bg-[var(--color-primary)]/10 p-2 rounded-lg">
|
||||||
onPageChange={handlePageChange}
|
<ChefHat className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
selection={{
|
</div>
|
||||||
mode: 'multiple',
|
<div>
|
||||||
selectedRows: selectedBatches,
|
<div className="font-medium text-[var(--text-primary)]">
|
||||||
onSelectionChange: handleBatchSelection,
|
{order.recipeName}
|
||||||
getRowId: (row) => row.id
|
</div>
|
||||||
}}
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
enableExport={true}
|
ID: {order.id}
|
||||||
onExport={handleExport}
|
</div>
|
||||||
density="normal"
|
</div>
|
||||||
horizontalScroll={true}
|
</div>
|
||||||
emptyStateMessage="No se encontraron órdenes de producción"
|
{getStatusBadge(order.status)}
|
||||||
emptyStateAction={{
|
</div>
|
||||||
label: "Nueva Orden",
|
|
||||||
onClick: () => console.log('Nueva orden de producción')
|
{/* Priority and Quantity */}
|
||||||
}}
|
<div className="flex items-center justify-between">
|
||||||
onRowClick={(row) => console.log('Ver detalles:', row)}
|
<div>
|
||||||
/>
|
{getPriorityBadge(order.priority)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||||
|
{order.quantity}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
unidades
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned Worker */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{order.assignedTo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Information */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Inicio:</span>
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{new Date(order.startTime).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Est. finalización:</span>
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">Progreso</span>
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{order.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
order.progress === 100
|
||||||
|
? 'bg-[var(--color-success)]'
|
||||||
|
: order.progress > 50
|
||||||
|
? 'bg-[var(--color-info)]'
|
||||||
|
: order.progress > 0
|
||||||
|
? 'bg-[var(--color-warning)]'
|
||||||
|
: 'bg-[var(--bg-quaternary)]'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${order.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredOrders.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron órdenes de producción
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Intenta ajustar la búsqueda o crear una nueva orden de producción
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nueva Orden de Producción
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'batches' && (
|
{activeTab === 'batches' && (
|
||||||
@@ -493,6 +387,54 @@ const ProductionPage: React.FC = () => {
|
|||||||
{activeTab === 'quality' && (
|
{activeTab === 'quality' && (
|
||||||
<QualityControl />
|
<QualityControl />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Production Order Form Modal - Placeholder */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[90vh] overflow-auto m-4 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{selectedOrder ? 'Ver Orden de Producción' : 'Nueva Orden de Producción'}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setSelectedOrder(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{selectedOrder && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">{selectedOrder.recipeName}</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Cantidad:</span> {selectedOrder.quantity} unidades
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Asignado a:</span> {selectedOrder.assignedTo}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Estado:</span> {selectedOrder.status}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Progreso:</span> {selectedOrder.progress}%
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Inicio:</span> {new Date(selectedOrder.startTime).toLocaleString('es-ES')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Finalización:</span> {new Date(selectedOrder.estimatedCompletion).toLocaleString('es-ES')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Star, Clock, Users, DollarSign } from 'lucide-react';
|
import { Plus, Download, Star, Clock, Users, DollarSign, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||||
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
|
||||||
const RecipesPage: React.FC = () => {
|
const RecipesPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedDifficulty, setSelectedDifficulty] = useState('all');
|
const [selectedRecipe, setSelectedRecipe] = useState<typeof mockRecipes[0] | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
||||||
|
|
||||||
const mockRecipes = [
|
const mockRecipes = [
|
||||||
{
|
{
|
||||||
@@ -76,46 +76,62 @@ const RecipesPage: React.FC = () => {
|
|||||||
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
id: '4',
|
||||||
const categories = [
|
name: 'Magdalenas de Limón',
|
||||||
{ value: 'all', label: 'Todas las categorías' },
|
category: 'pastry',
|
||||||
{ value: 'bread', label: 'Panes' },
|
difficulty: 'easy',
|
||||||
{ value: 'pastry', label: 'Bollería' },
|
prepTime: 20,
|
||||||
{ value: 'cake', label: 'Tartas' },
|
bakingTime: 25,
|
||||||
{ value: 'cookie', label: 'Galletas' },
|
yield: 12,
|
||||||
{ value: 'other', label: 'Otros' },
|
rating: 4.4,
|
||||||
];
|
cost: 3.80,
|
||||||
|
price: 9.00,
|
||||||
const difficulties = [
|
profit: 5.20,
|
||||||
{ value: 'all', label: 'Todas las dificultades' },
|
image: '/api/placeholder/300/200',
|
||||||
{ value: 'easy', label: 'Fácil' },
|
tags: ['cítrico', 'esponjoso', 'individual'],
|
||||||
{ value: 'medium', label: 'Medio' },
|
description: 'Magdalenas suaves y esponjosas con ralladura de limón.',
|
||||||
{ value: 'hard', label: 'Difícil' },
|
ingredients: [
|
||||||
|
{ name: 'Harina', quantity: 200, unit: 'g' },
|
||||||
|
{ name: 'Huevos', quantity: 3, unit: 'uds' },
|
||||||
|
{ name: 'Azúcar', quantity: 150, unit: 'g' },
|
||||||
|
{ name: 'Limón', quantity: 2, unit: 'uds' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getCategoryBadge = (category: string) => {
|
const getCategoryBadge = (category: string) => {
|
||||||
const categoryConfig = {
|
const categoryConfig = {
|
||||||
bread: { color: 'brown', text: 'Pan' },
|
bread: { color: 'default', text: 'Pan' },
|
||||||
pastry: { color: 'yellow', text: 'Bollería' },
|
pastry: { color: 'warning', text: 'Bollería' },
|
||||||
cake: { color: 'pink', text: 'Tarta' },
|
cake: { color: 'secondary', text: 'Tarta' },
|
||||||
cookie: { color: 'orange', text: 'Galleta' },
|
cookie: { color: 'info', text: 'Galleta' },
|
||||||
other: { color: 'gray', text: 'Otro' },
|
other: { color: 'outline', text: 'Otro' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
const config = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config.color as any}
|
||||||
|
text={config.text}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDifficultyBadge = (difficulty: string) => {
|
const getDifficultyBadge = (difficulty: string) => {
|
||||||
const difficultyConfig = {
|
const difficultyConfig = {
|
||||||
easy: { color: 'green', text: 'Fácil' },
|
easy: { color: 'success', text: 'Fácil' },
|
||||||
medium: { color: 'yellow', text: 'Medio' },
|
medium: { color: 'warning', text: 'Medio' },
|
||||||
hard: { color: 'red', text: 'Difícil' },
|
hard: { color: 'error', text: 'Difícil' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
const config = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config?.color as any}
|
||||||
|
text={config?.text || difficulty}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (minutes: number) => {
|
const formatTime = (minutes: number) => {
|
||||||
@@ -129,281 +145,300 @@ const RecipesPage: React.FC = () => {
|
|||||||
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
|
||||||
const matchesCategory = selectedCategory === 'all' || recipe.category === selectedCategory;
|
return matchesSearch;
|
||||||
const matchesDifficulty = selectedDifficulty === 'all' || recipe.difficulty === selectedDifficulty;
|
|
||||||
|
|
||||||
return matchesSearch && matchesCategory && matchesDifficulty;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockRecipeStats = {
|
||||||
|
totalRecipes: mockRecipes.length,
|
||||||
|
popularRecipes: mockRecipes.filter(r => r.rating > 4.7).length,
|
||||||
|
easyRecipes: mockRecipes.filter(r => r.difficulty === 'easy').length,
|
||||||
|
averageCost: mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length,
|
||||||
|
averageProfit: mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length,
|
||||||
|
categories: [...new Set(mockRecipes.map(r => r.category))].length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipeStats = [
|
||||||
|
{
|
||||||
|
title: 'Total Recetas',
|
||||||
|
value: mockRecipeStats.totalRecipes,
|
||||||
|
variant: 'default' as const,
|
||||||
|
icon: ChefHat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Populares',
|
||||||
|
value: mockRecipeStats.popularRecipes,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: Star,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fáciles',
|
||||||
|
value: mockRecipeStats.easyRecipes,
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: Timer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Costo Promedio',
|
||||||
|
value: formatters.currency(mockRecipeStats.averageCost),
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Margen Promedio',
|
||||||
|
value: formatters.currency(mockRecipeStats.averageProfit),
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Categorías',
|
||||||
|
value: mockRecipeStats.categories,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Gestión de Recetas"
|
title="Gestión de Recetas"
|
||||||
description="Administra y organiza todas las recetas de tu panadería"
|
description="Administra y organiza todas las recetas de tu panadería"
|
||||||
action={
|
actions={[
|
||||||
<Button>
|
{
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
id: "export",
|
||||||
Nueva Receta
|
label: "Exportar",
|
||||||
</Button>
|
variant: "outline" as const,
|
||||||
}
|
icon: Download,
|
||||||
|
onClick: () => console.log('Export recipes')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
label: "Nueva Receta",
|
||||||
|
variant: "primary" as const,
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => setShowForm(true)
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<StatsGrid
|
||||||
<Card className="p-6">
|
stats={recipeStats}
|
||||||
<div className="flex items-center justify-between">
|
columns={6}
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Recetas</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{mockRecipes.length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-6 w-6 text-[var(--color-info)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Simplified Controls */}
|
||||||
<div className="flex items-center justify-between">
|
<Card className="p-4">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Más Populares</p>
|
|
||||||
<p className="text-3xl font-bold text-yellow-600">
|
|
||||||
{mockRecipes.filter(r => r.rating > 4.7).length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Star className="h-12 w-12 text-yellow-600" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Costo Promedio</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-success)]">
|
|
||||||
€{(mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length).toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DollarSign className="h-12 w-12 text-[var(--color-success)]" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Margen Promedio</p>
|
|
||||||
<p className="text-3xl font-bold text-purple-600">
|
|
||||||
€{(mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length).toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters and Search */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<Input
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
||||||
<Input
|
value={searchTerm}
|
||||||
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
value={searchTerm}
|
className="w-full"
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
/>
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={selectedDifficulty}
|
|
||||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
>
|
|
||||||
{difficulties.map(diff => (
|
|
||||||
<option key={diff.value} value={diff.value}>{diff.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
|
||||||
>
|
|
||||||
{viewMode === 'grid' ? 'Vista Lista' : 'Vista Cuadrícula'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recipes Grid/List */}
|
{/* Recipes Grid */}
|
||||||
{viewMode === 'grid' ? (
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{filteredRecipes.map((recipe) => (
|
||||||
{filteredRecipes.map((recipe) => (
|
<Card key={recipe.id} className="p-4">
|
||||||
<Card key={recipe.id} className="overflow-hidden hover:shadow-lg transition-shadow">
|
<div className="space-y-4">
|
||||||
<div className="aspect-w-16 aspect-h-9">
|
{/* Header with Image */}
|
||||||
<img
|
<div className="flex items-start gap-3">
|
||||||
src={recipe.image}
|
<div className="flex-shrink-0">
|
||||||
alt={recipe.name}
|
<img
|
||||||
className="w-full h-48 object-cover"
|
src={recipe.image}
|
||||||
/>
|
alt={recipe.name}
|
||||||
</div>
|
className="w-16 h-16 rounded-lg object-cover bg-[var(--bg-secondary)]"
|
||||||
<div className="p-6">
|
/>
|
||||||
<div className="flex items-start justify-between mb-2">
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] line-clamp-1">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-[var(--text-primary)] truncate">
|
||||||
{recipe.name}
|
{recipe.name}
|
||||||
</h3>
|
|
||||||
<div className="flex items-center ml-2">
|
|
||||||
<Star className="h-4 w-4 text-yellow-400 fill-current" />
|
|
||||||
<span className="text-sm text-[var(--text-secondary)] ml-1">{recipe.rating}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<Star className="w-3 h-3 text-yellow-400 fill-current" />
|
||||||
<p className="text-[var(--text-secondary)] text-sm mb-3 line-clamp-2">
|
<span className="text-xs text-[var(--text-secondary)]">{recipe.rating}</span>
|
||||||
{recipe.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
|
||||||
{getCategoryBadge(recipe.category)}
|
|
||||||
{getDifficultyBadge(recipe.difficulty)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4 text-sm text-[var(--text-secondary)]">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
|
||||||
<span>{formatTime(recipe.prepTime + recipe.bakingTime)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<p className="text-xs text-[var(--text-secondary)] mt-1 line-clamp-2">
|
||||||
<Users className="h-4 w-4 mr-1" />
|
{recipe.description}
|
||||||
<span>{recipe.yield} porciones</span>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Costo: </span>
|
|
||||||
<span className="font-medium">€{recipe.cost.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Precio: </span>
|
|
||||||
<span className="font-medium text-[var(--color-success)]">€{recipe.price.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" className="flex-1">
|
|
||||||
Ver Receta
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" className="flex-1">
|
|
||||||
Producir
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
{/* Badges */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{getCategoryBadge(recipe.category)}
|
||||||
|
{getDifficultyBadge(recipe.difficulty)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time and Yield */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{formatTime(recipe.prepTime + recipe.bakingTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{recipe.yield} porciones
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Financial Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">Costo:</span>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{formatters.currency(recipe.cost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">Precio:</span>
|
||||||
|
<span className="font-medium text-[var(--color-success)]">
|
||||||
|
{formatters.currency(recipe.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">Margen:</span>
|
||||||
|
<span className="font-bold text-[var(--color-success)]">
|
||||||
|
{formatters.currency(recipe.profit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profit Margin Bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">Margen de beneficio</span>
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{Math.round((recipe.profit / recipe.price) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
(recipe.profit / recipe.price) > 0.5
|
||||||
|
? 'bg-[var(--color-success)]'
|
||||||
|
: (recipe.profit / recipe.price) > 0.3
|
||||||
|
? 'bg-[var(--color-warning)]'
|
||||||
|
: 'bg-[var(--color-error)]'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min((recipe.profit / recipe.price) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredients Count */}
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{recipe.ingredients.length} ingredientes principales
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-[var(--border-primary)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRecipe(recipe);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => console.log('Produce recipe', recipe.id)}
|
||||||
|
>
|
||||||
|
<ChefHat className="w-4 h-4 mr-1" />
|
||||||
|
Producir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredRecipes.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron recetas
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Intenta ajustar la búsqueda o crear una nueva receta
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nueva Receta
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recipe Form Modal - Placeholder */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[90vh] overflow-auto m-4 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
{selectedRecipe ? 'Ver Receta' : 'Nueva Receta'}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setSelectedRecipe(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{selectedRecipe && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<img
|
||||||
|
src={selectedRecipe.image}
|
||||||
|
alt={selectedRecipe.name}
|
||||||
|
className="w-full h-48 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
<h3 className="text-lg font-medium">{selectedRecipe.name}</h3>
|
||||||
|
<p className="text-[var(--text-secondary)]">{selectedRecipe.description}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Tiempo total:</span> {formatTime(selectedRecipe.prepTime + selectedRecipe.bakingTime)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Rendimiento:</span> {selectedRecipe.yield} porciones
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Ingredientes:</h4>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{selectedRecipe.ingredients.map((ing, i) => (
|
||||||
|
<li key={i} className="flex justify-between">
|
||||||
|
<span>{ing.name}</span>
|
||||||
|
<span>{ing.quantity} {ing.unit}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-[var(--bg-secondary)]">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Receta
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Categoría
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Dificultad
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Tiempo Total
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Rendimiento
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Costo
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Precio
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Margen
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
|
|
||||||
Acciones
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredRecipes.map((recipe) => (
|
|
||||||
<tr key={recipe.id} className="hover:bg-[var(--bg-secondary)]">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src={recipe.image}
|
|
||||||
alt={recipe.name}
|
|
||||||
className="h-10 w-10 rounded-full mr-4"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{recipe.name}</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
|
||||||
<span className="text-xs text-[var(--text-tertiary)] ml-1">{recipe.rating}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{getCategoryBadge(recipe.category)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{getDifficultyBadge(recipe.difficulty)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
|
||||||
{formatTime(recipe.prepTime + recipe.bakingTime)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
|
||||||
{recipe.yield} porciones
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
|
|
||||||
€{recipe.cost.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--color-success)] font-medium">
|
|
||||||
€{recipe.price.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-purple-600 font-medium">
|
|
||||||
€{recipe.profit.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" size="sm">Ver</Button>
|
|
||||||
<Button size="sm">Producir</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user